first commit

This commit is contained in:
2026-01-17 15:21:41 -05:00
commit b93d41c1ae
36 changed files with 3391 additions and 0 deletions

13
routes/auth/__init__.py Normal file
View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter
from .login import router as login_router
from .register import router as register_router
from .new import router as new_router
from .current_user import router as current_user_router, oauth2_scheme
from .lost_password import router as lost_password_router
router = APIRouter()
router.include_router(login_router)
router.include_router(register_router)
router.include_router(new_router)
router.include_router(current_user_router)
router.include_router(lost_password_router)

191
routes/auth/current_user.py Normal file
View File

@@ -0,0 +1,191 @@
from fastapi import Depends, HTTPException, status, APIRouter
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
import logging
from database import get_db
from models import Account_User, Customer_Customer, Customer_Description, Card
from schemas import TokenData, CustomerUpdate
from jose import JWTError, jwt
from config import load_config
logger = logging.getLogger(__name__)
# Load JWT configuration from environment
ApplicationConfig = load_config()
SECRET_KEY = ApplicationConfig.JWT_SECRET_KEY
ALGORITHM = ApplicationConfig.JWT_ALGORITHM
async def get_current_user(token: str = Depends(OAuth2PasswordBearer(tokenUrl="auth/login")), db: AsyncSession = Depends(get_db)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
user = await db.execute(select(Account_User).where(Account_User.username == token_data.username))
user = user.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
router = APIRouter()
@router.get("/me")
async def read_users_me(current_user: Account_User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
# Get customer details for the current user using user_id
customer = None
if current_user.user_id:
customer_result = await db.execute(select(Customer_Customer).where(Customer_Customer.id == current_user.user_id))
customer = customer_result.scalar_one_or_none()
# Get house description if exists
house_description = None
if customer:
description_record = await db.execute(
select(Customer_Description).where(Customer_Description.customer_id == customer.id)
)
description_record = description_record.scalar_one_or_none()
if description_record:
house_description = description_record.description
# Map state code to name
state_mapping = {0: "MA", 1: "NH"} # Add more as needed
state_name = state_mapping.get(customer.customer_state, str(customer.customer_state)) if customer else None
return {
"id": current_user.id,
"username": current_user.username,
"email": current_user.email,
"account_number": current_user.account_number,
"customer_first_name": customer.customer_first_name if customer else None,
"customer_last_name": customer.customer_last_name if customer else None,
"customer_address": customer.customer_address if customer else None,
"customer_apt": customer.customer_apt if customer else None,
"customer_town": customer.customer_town if customer else None,
"customer_state": state_name,
"customer_zip": customer.customer_zip if customer else None,
"customer_phone_number": customer.customer_phone_number if customer else None,
"customer_home_type": customer.customer_home_type if customer else None,
"customer_email": customer.customer_email if customer else None,
"house_description": house_description,
"member_since": current_user.member_since,
"last_seen": current_user.last_seen
}
@router.put("/update-customer")
async def update_customer_info(
customer_data: CustomerUpdate,
current_user: Account_User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
if not current_user.user_id:
raise HTTPException(status_code=404, detail="Customer not found")
try:
# Update Customer_Customer table
customer_update_data = customer_data.dict(exclude_unset=True)
customer_fields = {
'customer_first_name', 'customer_last_name', 'customer_address', 'customer_apt',
'customer_town', 'customer_state', 'customer_zip', 'customer_phone_number',
'customer_home_type', 'customer_email'
}
customer_update_dict = {k: v for k, v in customer_update_data.items() if k in customer_fields}
if customer_update_dict:
await db.execute(
update(Customer_Customer)
.where(Customer_Customer.id == current_user.user_id)
.values(**customer_update_dict)
)
# Update house description if provided
if 'house_description' in customer_update_data and customer_update_data['house_description']:
# Get customer record
customer = await db.execute(
select(Customer_Customer).where(Customer_Customer.id == current_user.user_id)
)
customer = customer.scalar_one_or_none()
if customer:
# Check if description record exists
description_record = await db.execute(
select(Customer_Description).where(Customer_Description.customer_id == customer.id)
)
description_record = description_record.scalar_one_or_none()
if description_record:
# Update existing
await db.execute(
update(Customer_Description)
.where(Customer_Description.customer_id == customer.id)
.values(description=customer_update_data['house_description'])
)
else:
# Create new
new_description = Customer_Description(
customer_id=customer.id,
account_number=current_user.account_number,
company_id=customer.company_id or 1,
fill_location=0,
description=customer_update_data['house_description']
)
db.add(new_description)
await db.commit()
# Sync billing info to Authorize.net if address-related fields were updated
billing_fields = {
'customer_first_name', 'customer_last_name', 'customer_address',
'customer_town', 'customer_state', 'customer_zip', 'customer_phone_number',
'customer_email'
}
if any(k in customer_update_dict for k in billing_fields):
# Late import to avoid circular dependency
from routes.payment.routes import update_customer_profile, update_payment_profile_billing
# Refetch the updated customer
customer_result = await db.execute(
select(Customer_Customer).where(Customer_Customer.id == current_user.user_id)
)
updated_customer = customer_result.scalar_one_or_none()
if updated_customer and updated_customer.auth_net_profile_id:
# Update customer profile in Authorize.net
update_customer_profile(updated_customer.auth_net_profile_id, updated_customer)
# Update all saved payment profiles with new billing info
cards_result = await db.execute(
select(Card).where(Card.user_id == updated_customer.id)
)
cards = cards_result.scalars().all()
for card in cards:
if card.auth_net_payment_profile_id:
update_payment_profile_billing(
customer_profile_id=updated_customer.auth_net_profile_id,
payment_profile_id=card.auth_net_payment_profile_id,
customer=updated_customer,
card=card
)
logger.info(f"Synced billing info to Authorize.net for customer {updated_customer.id}")
return {"message": "Customer information updated successfully"}
except Exception as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update customer information: {str(e)}"
)

60
routes/auth/login.py Normal file
View File

@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from database import get_db
from models import Account_User
from schemas import UserLogin, Token
from passlib.context import CryptContext
from jose import jwt
from datetime import datetime, timedelta
from config import load_config
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
# Load JWT configuration from environment
ApplicationConfig = load_config()
SECRET_KEY = ApplicationConfig.JWT_SECRET_KEY
ALGORITHM = ApplicationConfig.JWT_ALGORITHM
ACCESS_TOKEN_EXPIRE_MINUTES = ApplicationConfig.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict, expires_delta = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def authenticate_user(db: AsyncSession, email: str, password: str):
user = await db.execute(select(Account_User).where(Account_User.email == email))
user = user.scalar_one_or_none()
if not user:
return False
if not verify_password(password, user.password_hash):
return False
# Update last_seen
user.last_seen = datetime.utcnow()
await db.commit()
return user
router = APIRouter()
@router.post("/login", response_model=Token)
async def login(user: UserLogin, db: AsyncSession = Depends(get_db)):
user_obj = await authenticate_user(db, user.email, user.password)
if not user_obj:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user_obj.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

View File

@@ -0,0 +1,126 @@
import secrets
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from database import get_db
from models import Account_User
from schemas import ForgotPasswordRequest, ResetPasswordRequest
from passlib.context import CryptContext
from config import load_config
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
# Load configuration from environment
ApplicationConfig = load_config()
# Email configuration from environment variables
SMTP_SERVER = ApplicationConfig.SMTP_SERVER
SMTP_PORT = ApplicationConfig.SMTP_PORT
SMTP_USERNAME = ApplicationConfig.SMTP_USERNAME
SMTP_PASSWORD = ApplicationConfig.SMTP_PASSWORD
FROM_EMAIL = ApplicationConfig.SMTP_FROM_EMAIL
router = APIRouter()
def get_password_hash(password):
return pwd_context.hash(password)
def generate_reset_token():
return secrets.token_urlsafe(32)
def send_reset_email(to_email: str, reset_token: str):
"""Send password reset email with link"""
reset_url = f"{ApplicationConfig.FRONTEND_URL}/reset-password?token={reset_token}"
msg = MIMEMultipart()
msg['From'] = FROM_EMAIL
msg['To'] = to_email
msg['Subject'] = "Password Reset - Oil Customer Gateway"
body = f"""
You have requested a password reset for your Oil Customer Gateway account.
Click the following link to reset your password:
{reset_url}
This link will expire in 24 hours.
If you didn't request this reset, please ignore this email.
Best regards,
Oil Customer Gateway Team
"""
msg.attach(MIMEText(body, 'plain'))
try:
server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
server.starttls()
server.login(SMTP_USERNAME, SMTP_PASSWORD)
text = msg.as_string()
server.sendmail(FROM_EMAIL, to_email, text)
server.quit()
return True
except Exception as e:
print(f"Email sending failed: {e}")
return False
@router.post("/forgot-password")
async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
"""Request password reset - sends email with reset link"""
# Find user by email
result = await db.execute(select(Account_User).where(Account_User.email == request.email))
user = result.scalar_one_or_none()
if not user:
# Don't reveal if email exists or not for security
return {"message": "If an account with that email exists, a password reset link has been sent."}
# Generate reset token and expiry (24 hours)
reset_token = generate_reset_token()
reset_expires = datetime.utcnow() + timedelta(hours=24)
# Update user with reset token
user.password_reset_token = reset_token
user.password_reset_expires = reset_expires
await db.commit()
# Send email
if send_reset_email(user.email, reset_token):
return {"message": "Password reset link sent to your email."}
else:
raise HTTPException(status_code=500, detail="Failed to send reset email")
@router.post("/reset-password")
async def reset_password(request: ResetPasswordRequest, db: AsyncSession = Depends(get_db)):
"""Reset password using token"""
# Verify passwords match
if request.password != request.confirm_password:
raise HTTPException(status_code=400, detail="Passwords do not match")
# Find user by reset token
result = await db.execute(
select(Account_User).where(
Account_User.password_reset_token == request.token,
Account_User.password_reset_expires > datetime.utcnow()
)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
# Update password
hashed_password = get_password_hash(request.password)
user.password_hash = hashed_password
user.password_reset_token = None
user.password_reset_expires = None
user.last_seen = datetime.utcnow()
await db.commit()
return {"message": "Password reset successfully"}

588
routes/auth/new.py Normal file
View File

@@ -0,0 +1,588 @@
import random
import string
import os
import shutil
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import Customer_Customer, Customer_Description, Customer_Tank_Inspection, Customer_Stats, Account_User
from schemas import NewCustomerCreate, UserResponse, CustomerCreateStep1, CustomerAccountCreate
from passlib.context import CryptContext
from datetime import datetime
from PIL import Image
import io
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def generate_random_number_string(length):
if length < 1:
raise ValueError("Length must be at least 1")
random_number = ''.join(random.choices(string.digits, k=length))
return random_number
def get_password_hash(password):
# Truncate password to 72 bytes max for bcrypt compatibility
truncated_password = password.encode('utf-8')[:72].decode('utf-8', errors='ignore')
return pwd_context.hash(truncated_password)
def resize_image(image_data, max_size=(1024, 1024), quality=85):
"""
Resize image to fit within max_size while maintaining aspect ratio.
Convert to JPEG format and strip metadata for security.
"""
try:
# Open image from bytes
img = Image.open(io.BytesIO(image_data))
# Convert to RGB if necessary (for PNG with transparency, etc.)
if img.mode in ('RGBA', 'LA', 'P'):
img = img.convert('RGB')
# Resize if larger than max_size
if img.width > max_size[0] or img.height > max_size[1]:
img.thumbnail(max_size, Image.Resampling.LANCZOS)
# Save as JPEG with specified quality
output = io.BytesIO()
img.save(output, format='JPEG', quality=quality, optimize=True)
output.seek(0)
return output.getvalue()
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid image file: {str(e)}")
router = APIRouter()
@router.post("/new", response_model=UserResponse)
async def register_new_customer(customer: NewCustomerCreate, db: AsyncSession = Depends(get_db)):
# Verify passwords match
if customer.password != customer.confirm_password:
raise HTTPException(status_code=400, detail="Passwords do not match")
# Check if email already registered
result = await db.execute(select(Account_User).where(Account_User.email == customer.customer_email))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
while True:
random_part = generate_random_number_string(6)
account_number = 'AO-' + random_part
result = await db.execute(select(Customer_Customer).where(Customer_Customer.account_number == account_number))
existing_customer = result.scalar_one_or_none()
if not existing_customer:
break
# Create customer
customer_data = customer.model_dump()
customer_data.pop('password')
customer_data.pop('confirm_password')
customer_data.update({
'account_number': account_number,
'customer_state': 1, # Default
'customer_automatic': 0,
'company_id': 1, # Default
'customer_latitude': '0',
'customer_longitude': '0',
'correct_address': True,
'customer_first_call': datetime.utcnow()
})
db_customer = Customer_Customer(**customer_data)
db.add(db_customer)
await db.commit()
await db.refresh(db_customer)
# Extract house number from customer address (first part before space)
house_number = customer.customer_address.split()[0] if customer.customer_address else ''
# Create account user
username = account_number
hashed_password = get_password_hash(customer.password)
db_user = Account_User(
username=username,
account_number=account_number,
house_number=house_number,
password_hash=hashed_password,
member_since=datetime.utcnow(),
email=customer.customer_email,
last_seen=datetime.utcnow(),
admin=0,
admin_role=0,
confirmed=1,
active=1,
user_id=db_customer.id
)
db.add(db_user)
await db.commit()
await db.refresh(db_user)
return db_user
@router.post("/step3")
async def upload_tank_images(
account_number: str = Form(...),
tank_image_1: UploadFile = File(...),
tank_image_2: UploadFile = File(...),
tank_image_3: UploadFile = File(...),
db: AsyncSession = Depends(get_db)
):
print("=== STEP3 DEBUG START ===")
print(f"Account number received: '{account_number}'")
# Debug: Check all parameters received
images = [tank_image_1, tank_image_2, tank_image_3]
for i, image in enumerate(images, 1):
print(f"Image {i}: filename='{image.filename}', content_type='{image.content_type}', size={image.size}")
# Validate account number
if not account_number:
print("ERROR: Account number is empty")
raise HTTPException(status_code=400, detail="Account number is required")
# Get customer info for description record
customer_result = await db.execute(select(Customer_Customer).where(Customer_Customer.account_number == account_number))
customer = customer_result.scalar_one_or_none()
if not customer:
raise HTTPException(status_code=400, detail="Customer not found")
print(f"Creating directory: /images/{account_number}")
# Create directory for account number in the mounted images volume
account_dir = f"/images/{account_number}"
os.makedirs(account_dir, exist_ok=True)
current_datetime = datetime.utcnow().strftime("%Y-%m-%d_%H-%M-%S") # YYYY-MM-DD_HH-MM-SS format
# Track if images were uploaded successfully
images_uploaded_successfully = False
# Validate and save images
saved_files = []
try:
for i, image in enumerate(images, 1):
print(f"Processing image {i}...")
# Read image data
image_data = await image.read()
print(f"Image {i} data read: {len(image_data)} bytes")
# Validate file size (max 20MB before processing)
if len(image_data) > 20 * 1024 * 1024:
print(f"ERROR: Image {i} too large: {len(image_data)} bytes")
raise HTTPException(status_code=400, detail=f"File {i} is too large (max 20MB)")
# Resize and process image (this also validates it's a valid image)
print(f"Resizing image {i}...")
processed_image_data = resize_image(image_data, max_size=(1024, 1024), quality=85)
print(f"Image {i} resized: {len(processed_image_data)} bytes")
# Save processed image with datetime-based filename
filename = f"{current_datetime}-{i}.jpg"
file_path = os.path.join(account_dir, filename)
print(f"Saving image {i} to: {file_path}")
with open(file_path, "wb") as buffer:
buffer.write(processed_image_data)
saved_files.append(filename)
print(f"Image {i} saved successfully")
images_uploaded_successfully = True
print(f"All images processed successfully. Saved files: {saved_files}")
except Exception as e:
print(f"ERROR processing images: {str(e)}")
# Don't raise exception - we want to track the failure in the database
images_uploaded_successfully = False
# Update or create customer tank inspection record with tank_images status
print("Updating customer tank inspection record...")
tank_result = await db.execute(
select(Customer_Tank_Inspection).where(Customer_Tank_Inspection.customer_id == customer.id)
)
existing_tank = tank_result.scalar_one_or_none()
if existing_tank:
# Update existing record
if images_uploaded_successfully:
existing_tank.tank_images += 1 # Increment count of image sets
# Append current datetime to upload dates list
current_dates = existing_tank.tank_image_upload_dates or []
current_dates.append(current_datetime)
existing_tank.tank_image_upload_dates = current_dates
await db.commit()
print(f"Updated existing tank inspection record with tank_images = {existing_tank.tank_images}, dates = {existing_tank.tank_image_upload_dates}")
else:
# Create new record
new_tank = Customer_Tank_Inspection(
customer_id=customer.id,
tank_images=1 if images_uploaded_successfully else 0,
tank_image_upload_dates=[current_datetime] if images_uploaded_successfully else []
# Other fields will be null/None initially
)
db.add(new_tank)
await db.commit()
print(f"Created new tank inspection record with tank_images = {new_tank.tank_images}, dates = {new_tank.tank_image_upload_dates}")
print("=== STEP3 DEBUG END ===")
if images_uploaded_successfully:
return {
"message": "Tank images uploaded successfully",
"account_number": account_number,
"uploaded_files": saved_files
}
else:
return {
"message": "Tank images upload skipped or failed",
"account_number": account_number,
"uploaded_files": []
}
@router.post("/upload-tank-images")
async def upload_additional_tank_images(
account_number: str = Form(...),
tank_image_1: UploadFile = File(...),
tank_image_2: UploadFile = File(...),
tank_image_3: UploadFile = File(...),
db: AsyncSession = Depends(get_db)
):
"""
Endpoint for customers to upload additional sets of 3 tank images after registration.
Similar to step3 but for existing customers.
"""
print("=== UPLOAD ADDITIONAL TANK IMAGES DEBUG START ===")
print(f"Account number received: '{account_number}'")
# Debug: Check all parameters received
images = [tank_image_1, tank_image_2, tank_image_3]
for i, image in enumerate(images, 1):
print(f"Image {i}: filename='{image.filename}', content_type='{image.content_type}', size={image.size}")
# Validate account number
if not account_number:
print("ERROR: Account number is empty")
raise HTTPException(status_code=400, detail="Account number is required")
# Get customer info
customer_result = await db.execute(select(Customer_Customer).where(Customer_Customer.account_number == account_number))
customer = customer_result.scalar_one_or_none()
if not customer:
raise HTTPException(status_code=400, detail="Customer not found")
print(f"Creating directory: /images/{account_number}")
# Create directory for account number in the mounted images volume
account_dir = f"/images/{account_number}"
os.makedirs(account_dir, exist_ok=True)
current_datetime = datetime.utcnow().strftime("%Y-%m-%d_%H-%M-%S") # YYYY-MM-DD_HH-MM-SS format
# Track if images were uploaded successfully
images_uploaded_successfully = False
# Validate and save images
saved_files = []
try:
for i, image in enumerate(images, 1):
print(f"Processing image {i}...")
# Read image data
image_data = await image.read()
print(f"Image {i} data read: {len(image_data)} bytes")
# Validate file size (max 20MB before processing)
if len(image_data) > 20 * 1024 * 1024:
print(f"ERROR: Image {i} too large: {len(image_data)} bytes")
raise HTTPException(status_code=400, detail=f"File {i} is too large (max 20MB)")
# Resize and process image (this also validates it's a valid image)
print(f"Resizing image {i}...")
processed_image_data = resize_image(image_data, max_size=(1024, 1024), quality=85)
print(f"Image {i} resized: {len(processed_image_data)} bytes")
# Save processed image with datetime-based filename
filename = f"{current_datetime}-{i}.jpg"
file_path = os.path.join(account_dir, filename)
print(f"Saving image {i} to: {file_path}")
with open(file_path, "wb") as buffer:
buffer.write(processed_image_data)
saved_files.append(filename)
print(f"Image {i} saved successfully")
images_uploaded_successfully = True
print(f"All images processed successfully. Saved files: {saved_files}")
except Exception as e:
print(f"ERROR processing images: {str(e)}")
# Don't raise exception - we want to track the failure in the database
images_uploaded_successfully = False
# Update customer tank inspection record
print("Updating customer tank inspection record...")
tank_result = await db.execute(
select(Customer_Tank_Inspection).where(Customer_Tank_Inspection.customer_id == customer.id)
)
existing_tank = tank_result.scalar_one_or_none()
if existing_tank:
# Update existing record
if images_uploaded_successfully:
existing_tank.tank_images += 1 # Increment count of image sets
# Append current datetime to upload dates list
current_dates = existing_tank.tank_image_upload_dates or []
current_dates.append(current_datetime)
existing_tank.tank_image_upload_dates = current_dates
await db.commit()
print(f"Updated existing tank inspection record with tank_images = {existing_tank.tank_images}, dates = {existing_tank.tank_image_upload_dates}")
else:
# This shouldn't happen for additional uploads, but handle it just in case
new_tank = Customer_Tank_Inspection(
customer_id=customer.id,
tank_images=1 if images_uploaded_successfully else 0,
tank_image_upload_dates=[current_datetime] if images_uploaded_successfully else []
)
db.add(new_tank)
await db.commit()
print(f"Created new tank inspection record with tank_images = {new_tank.tank_images}, dates = {new_tank.tank_image_upload_dates}")
print("=== UPLOAD ADDITIONAL TANK IMAGES DEBUG END ===")
if images_uploaded_successfully:
return {
"message": "Additional tank images uploaded successfully",
"account_number": account_number,
"uploaded_files": saved_files,
"upload_date": current_datetime
}
else:
raise HTTPException(status_code=400, detail="Failed to upload tank images")
@router.get("/tank-images/{account_number}")
async def get_tank_images(account_number: str, db: AsyncSession = Depends(get_db)):
"""
Get tank images information for a customer including upload dates.
"""
# Get customer info
customer_result = await db.execute(select(Customer_Customer).where(Customer_Customer.account_number == account_number))
customer = customer_result.scalar_one_or_none()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
# Get tank inspection record
tank_result = await db.execute(
select(Customer_Tank_Inspection).where(Customer_Tank_Inspection.customer_id == customer.id)
)
tank_record = tank_result.scalar_one_or_none()
# Build image sets - first from database, then scan for any additional files
image_sets = []
upload_dates = tank_record.tank_image_upload_dates or [] if tank_record else []
# Add sets from database
for i, upload_date in enumerate(upload_dates):
# Handle backward compatibility: old format used date-only strings and tank_*.jpg files
# New format uses datetime strings and {datetime}-*.jpg files
if "_" in upload_date:
# New datetime format (YYYY-MM-DD_HH-MM-SS)
image_set = {
"date": upload_date,
"images": [
f"/images/{account_number}/{upload_date}-1.jpg",
f"/images/{account_number}/{upload_date}-2.jpg",
f"/images/{account_number}/{upload_date}-3.jpg"
]
}
else:
# Old date-only format (YYYY-MM-DD) - uses tank_*.jpg files
image_set = {
"date": upload_date,
"images": [
f"/images/{account_number}/tank_1.jpg",
f"/images/{account_number}/tank_2.jpg",
f"/images/{account_number}/tank_3.jpg"
]
}
image_sets.append(image_set)
# Scan for any additional image sets that might not be in database
account_dir = f"/images/{account_number}"
if os.path.exists(account_dir):
# Find all image files
all_files = os.listdir(account_dir)
image_files = [f for f in all_files if f.endswith('.jpg')]
# Group by datetime prefix
datetime_groups = {}
for file in image_files:
if file.startswith('tank_'):
# Old tank_*.jpg files - already handled above
continue
elif '_' in file and file.endswith('.jpg'): # datetime format like 2026-01-08_23-25-31-1.jpg
# Extract datetime prefix by removing the image number and .jpg
# Example: 2026-01-08_23-25-31-1.jpg -> 2026-01-08_23-25-31
datetime_prefix = file.rsplit('-', 1)[0] # Remove everything after last dash
if datetime_prefix not in datetime_groups:
datetime_groups[datetime_prefix] = []
datetime_groups[datetime_prefix].append(file)
# Add any datetime groups not already in database
for datetime_prefix, files in datetime_groups.items():
if datetime_prefix not in upload_dates and len(files) >= 3:
# Sort files to ensure correct order
sorted_files = sorted(files)
image_set = {
"date": datetime_prefix,
"images": [
f"/images/{account_number}/{sorted_files[0]}",
f"/images/{account_number}/{sorted_files[1]}",
f"/images/{account_number}/{sorted_files[2]}"
]
}
image_sets.append(image_set)
# Also check for date-prefixed files (like 2026-01-08-1.jpg)
date_groups = {}
for file in image_files:
if file.startswith('tank_') or '_' in file:
continue # Skip old format and datetime format
parts = file.split('-')
if len(parts) == 4 and parts[3] in ['1.jpg', '2.jpg', '3.jpg']:
date_prefix = '-'.join(parts[:3]) # 2026-01-08
if date_prefix not in date_groups:
date_groups[date_prefix] = []
date_groups[date_prefix].append(file)
# Add date groups not already in database
for date_prefix, files in date_groups.items():
if date_prefix not in upload_dates and len(files) >= 3:
# Sort files to ensure correct order
sorted_files = sorted(files)
image_set = {
"date": date_prefix,
"images": [
f"/images/{account_number}/{sorted_files[0]}",
f"/images/{account_number}/{sorted_files[1]}",
f"/images/{account_number}/{sorted_files[2]}"
]
}
image_sets.append(image_set)
# Sort image sets by date descending (newest first)
def sort_key(item):
date_str = item['date']
try:
if '_' in date_str:
return datetime.strptime(date_str, "%Y-%m-%d_%H-%M-%S")
else:
return datetime.strptime(date_str, "%Y-%m-%d")
except:
return datetime.min
image_sets.sort(key=sort_key, reverse=True)
return {
"account_number": account_number,
"image_sets": image_sets
}
@router.post("/step1")
async def create_customer_step1(customer: CustomerCreateStep1, db: AsyncSession = Depends(get_db)):
while True:
random_part = generate_random_number_string(6)
account_number = 'AO-' + random_part
result = await db.execute(select(Customer_Customer).where(Customer_Customer.account_number == account_number))
existing_customer = result.scalar_one_or_none()
if not existing_customer:
break
# Extract house_description for separate table
house_description = customer.house_description
# Create customer
customer_data = customer.model_dump()
customer_data.pop('house_description') # Remove from customer data
customer_data.update({
'account_number': account_number,
'customer_state': 1, # Default
'customer_automatic': 0,
'company_id': 1, # Default
'customer_latitude': '0',
'customer_longitude': '0',
'correct_address': True,
'customer_first_call': datetime.utcnow()
})
db_customer = Customer_Customer(**customer_data)
db.add(db_customer)
await db.commit()
await db.refresh(db_customer)
# Create customer description if house_description provided
if house_description:
db_description = Customer_Description(
customer_id=db_customer.id,
account_number=account_number,
company_id=1, # Default
fill_location=None, # Will work on this later
description=house_description
# tank_images is now tracked in customer_tank table
)
db.add(db_description)
await db.commit()
# Create customer stats record for tracking metrics
db_stats = Customer_Stats(
customer_id=db_customer.id
# All other fields default to 0/0.00 as defined in the model
)
db.add(db_stats)
await db.commit()
return {"account_number": account_number}
@router.post("/step2", response_model=UserResponse)
async def create_customer_account(account_data: CustomerAccountCreate, db: AsyncSession = Depends(get_db)):
# Verify passwords match
if account_data.password != account_data.confirm_password:
raise HTTPException(status_code=400, detail="Passwords do not match")
# Check if customer exists
result = await db.execute(select(Customer_Customer).where(Customer_Customer.account_number == account_data.account_number))
customer = result.scalar_one_or_none()
if not customer:
raise HTTPException(status_code=400, detail="Customer not found")
# Check if email already registered
result = await db.execute(select(Account_User).where(Account_User.email == account_data.customer_email))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
# Update customer email
customer.customer_email = account_data.customer_email
db.add(customer)
await db.commit()
# Extract house number from customer address (first part before space)
house_number = customer.customer_address.split()[0] if customer.customer_address else ''
# Create account user
username = account_data.account_number
hashed_password = get_password_hash(account_data.password)
db_user = Account_User(
username=username,
account_number=account_data.account_number,
house_number=house_number,
password_hash=hashed_password,
member_since=datetime.utcnow(),
email=account_data.customer_email,
last_seen=datetime.utcnow(),
admin=0,
admin_role=0,
confirmed=1,
active=1,
user_id=customer.id
)
db.add(db_user)
await db.commit()
await db.refresh(db_user)
return db_user

71
routes/auth/register.py Normal file
View File

@@ -0,0 +1,71 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from database import get_db
from models import Account_User, Customer_Customer
from schemas import UserCreate, UserResponse
from passlib.context import CryptContext
from datetime import datetime
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def get_password_hash(password):
return pwd_context.hash(password)
def escape_like_pattern(value: str) -> str:
"""Escape special characters for SQL LIKE patterns.
Escapes %, _, and \ which have special meaning in LIKE clauses
to prevent SQL injection via wildcards.
"""
# Escape backslash first, then the wildcards
return value.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
router = APIRouter()
@router.post("/register", response_model=UserResponse)
async def register(user: UserCreate, db: AsyncSession = Depends(get_db)):
# Verify passwords match
if user.password != user.confirm_password:
raise HTTPException(status_code=400, detail="Passwords do not match")
# Check if customer exists in Customer_Customer table
# Escape SQL LIKE wildcards to prevent injection attacks
escaped_house_number = escape_like_pattern(user.house_number)
customer_result = await db.execute(
select(Customer_Customer).where(
(Customer_Customer.account_number == user.account_number) &
(Customer_Customer.customer_address.like(f'{escaped_house_number} %', escape='\\'))
)
)
customer = customer_result.scalar_one_or_none()
if not customer:
raise HTTPException(status_code=400, detail="Customer not found with provided account and house number")
# Check if email already registered
result = await db.execute(select(Account_User).where(Account_User.email == user.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
username = f"{user.account_number}-{user.house_number}"
hashed_password = get_password_hash(user.password)
db_user = Account_User(
username=username,
account_number=user.account_number,
house_number=user.house_number,
password_hash=hashed_password,
member_since=datetime.utcnow(),
email=user.email,
last_seen=datetime.utcnow(),
admin=0,
admin_role=0,
confirmed=1,
active=1,
user_id=customer.id
)
db.add(db_user)
await db.commit()
await db.refresh(db_user)
return db_user