Initial Commit

This commit is contained in:
2026-06-11 15:05:08 +08:00
commit ea8e41e688
21 changed files with 2317 additions and 0 deletions
+368
View File
@@ -0,0 +1,368 @@
import os
import uuid
from typing import Annotated, Optional
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from starlette import status
from auth import get_db, get_current_user
from config import templates
from models import Property, PropertyImage, Favorite, User
router = APIRouter(tags=["properties"])
PAGE_SIZE = 9
def _primary_image(prop):
return next((img for img in prop.images if img.is_primary), prop.images[0] if prop.images else None)
def _apply_image(props):
for p in props:
p.primary_image = _primary_image(p)
return props
@router.get("/properties", response_class=HTMLResponse)
async def property_list(request: Request, db: Session = Depends(get_db)):
search = request.query_params.get("search", "").strip()
prop_type = request.query_params.get("type", "").strip()
min_price = request.query_params.get("min_price", "").strip()
max_price = request.query_params.get("max_price", "").strip()
bedrooms = request.query_params.get("bedrooms", "").strip()
city = request.query_params.get("city", "").strip()
state = request.query_params.get("state", "").strip()
page = int(request.query_params.get("page", 1))
query = db.query(Property).filter(Property.status == "active")
if search:
like = f"%{search}%"
query = query.filter(
(Property.title.ilike(like)) |
(Property.city.ilike(like)) |
(Property.state.ilike(like)) |
(Property.zip_code.ilike(like)) |
(Property.address.ilike(like))
)
if prop_type:
query = query.filter(Property.property_type == prop_type)
if min_price:
query = query.filter(Property.price >= int(min_price))
if max_price:
query = query.filter(Property.price <= int(max_price))
if bedrooms:
query = query.filter(Property.bedrooms >= int(bedrooms))
if city:
query = query.filter(Property.city.ilike(f"%{city}%"))
if state:
query = query.filter(Property.state.ilike(f"%{state}%"))
total = query.count()
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
page = max(1, min(page, total_pages))
properties = query.order_by(Property.created_at.desc()).offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE).all()
_apply_image(properties)
params = {}
for k in ("search", "type", "min_price", "max_price", "bedrooms", "city", "state"):
v = request.query_params.get(k, "")
if v:
params[k] = v
query_string = urlencode(params)
user = await get_current_user(request, None)
return templates.TemplateResponse(request, "properties/list.html", {
"user": user,
"properties": properties,
"total_pages": total_pages,
"page": page,
"query_string": query_string,
"search": search,
"prop_type": prop_type,
"min_price": min_price,
"max_price": max_price,
"bedrooms": bedrooms,
"city_filter": city,
"state_filter": state,
})
@router.get("/properties/new", response_class=HTMLResponse)
async def property_create_form(request: Request, db: Session = Depends(get_db)):
user = await get_current_user(request, None)
if not user:
return RedirectResponse(url="/auth/login", status_code=303)
return templates.TemplateResponse(request, "properties/create.html", {
"user": user,
"values": {},
})
async def _save_property_image(image_file: UploadFile) -> str | None:
if not image_file or not image_file.filename:
return None
ext = os.path.splitext(image_file.filename)[1].lower()
if ext not in (".jpg", ".jpeg", ".png", ".webp"):
return None
upload_dir = "static/properties"
os.makedirs(upload_dir, exist_ok=True)
filename = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(upload_dir, filename)
content = await image_file.read()
if len(content) > 5 * 1024 * 1024:
return None
with open(filepath, "wb") as f:
f.write(content)
return f"properties/{filename}"
@router.post("/properties/new")
async def property_create_submit(
request: Request,
db: Session = Depends(get_db),
title: str = Form(...),
description: str = Form(...),
price: int = Form(...),
property_type: str = Form(...),
bedrooms: int = Form(0),
bathrooms: float = Form(0),
area_sqft: int = Form(...),
address: str = Form(...),
city: str = Form(...),
state: str = Form(...),
zip_code: str = Form(...),
year_built: int | None = Form(None),
contact_email: str = Form(""),
contact_phone: str = Form(""),
image: UploadFile | None = File(None),
):
user = await get_current_user(request, None)
if not user:
return RedirectResponse(url="/auth/login", status_code=303)
contact_email = contact_email.strip()
contact_phone = contact_phone.strip()
if not contact_email and not contact_phone:
return templates.TemplateResponse(request, "properties/create.html", {
"user": user,
"values": {k: v for k, v in request._form.items()} if hasattr(request, '_form') else {},
"error": "Please provide at least one contact method (email or phone).",
})
prop = Property(
title=title,
description=description,
price=price,
property_type=property_type,
bedrooms=bedrooms,
bathrooms=bathrooms,
area_sqft=area_sqft,
address=address,
city=city,
state=state.upper(),
zip_code=zip_code,
year_built=year_built or None,
contact_email=contact_email or None,
contact_phone=contact_phone or None,
owner_id=user["id"],
)
db.add(prop)
db.flush()
image_path = await _save_property_image(image)
if image_path:
db.add(PropertyImage(image_path=image_path, is_primary=True, sort_order=0, property_id=prop.id))
db.commit()
return RedirectResponse(url=f"/properties/{prop.id}", status_code=303)
# ---------------------------------------------------------------------------
# Parameterized routes — after all static paths
# ---------------------------------------------------------------------------
@router.get("/properties/{prop_id}", response_class=HTMLResponse)
async def property_detail(prop_id: int, request: Request, db: Session = Depends(get_db)):
prop = db.query(Property).filter(Property.id == prop_id).first()
if not prop:
raise HTTPException(status_code=404, detail="Property not found")
primary_image = _primary_image(prop)
images = sorted(prop.images, key=lambda img: img.sort_order)
user = await get_current_user(request, None)
is_favorited = False
if user:
is_favorited = db.query(Favorite).filter(
Favorite.user_id == user["id"],
Favorite.property_id == prop.id,
).first() is not None
return templates.TemplateResponse(request, "properties/detail.html", {
"user": user,
"prop": prop,
"primary_image": primary_image,
"images": images,
"is_favorited": is_favorited,
})
@router.get("/properties/{prop_id}/edit", response_class=HTMLResponse)
async def property_edit_form(prop_id: int, request: Request, db: Session = Depends(get_db)):
user = await get_current_user(request, None)
if not user:
return RedirectResponse(url="/auth/login", status_code=303)
prop = db.query(Property).filter(Property.id == prop_id).first()
if not prop:
raise HTTPException(status_code=404, detail="Property not found")
if prop.owner_id != user["id"]:
raise HTTPException(status_code=403, detail="Not authorized")
primary_image = _primary_image(prop)
return templates.TemplateResponse(request, "properties/edit.html", {
"user": user,
"prop": prop,
"primary_image": primary_image,
})
@router.post("/properties/{prop_id}/edit")
async def property_edit_submit(
prop_id: int,
request: Request,
db: Session = Depends(get_db),
title: str = Form(...),
description: str = Form(...),
price: int = Form(...),
property_type: str = Form(...),
bedrooms: int = Form(0),
bathrooms: float = Form(0),
area_sqft: int = Form(...),
address: str = Form(...),
city: str = Form(...),
state: str = Form(...),
zip_code: str = Form(...),
year_built: int | None = Form(None),
status: str = Form("active"),
contact_email: str = Form(""),
contact_phone: str = Form(""),
image: UploadFile | None = File(None),
):
user = await get_current_user(request, None)
if not user:
return RedirectResponse(url="/auth/login", status_code=303)
prop = db.query(Property).filter(Property.id == prop_id).first()
if not prop:
raise HTTPException(status_code=404, detail="Property not found")
if prop.owner_id != user["id"]:
raise HTTPException(status_code=403, detail="Not authorized")
prop.title = title
prop.description = description
prop.price = price
prop.property_type = property_type
prop.bedrooms = bedrooms
prop.bathrooms = bathrooms
prop.area_sqft = area_sqft
prop.address = address
prop.city = city
prop.state = state.upper()
prop.zip_code = zip_code
prop.year_built = year_built or None
prop.contact_email = contact_email.strip() or None
prop.contact_phone = contact_phone.strip() or None
prop.status = status
image_path = await _save_property_image(image)
if image_path:
for img in prop.images:
old_path = os.path.join("static", img.image_path)
if os.path.exists(old_path):
os.remove(old_path)
db.delete(img)
db.flush()
db.add(PropertyImage(image_path=image_path, is_primary=True, sort_order=0, property_id=prop.id))
db.commit()
return RedirectResponse(url=f"/properties/{prop.id}", status_code=303)
@router.post("/properties/{prop_id}/delete")
async def property_delete(prop_id: int, request: Request, db: Session = Depends(get_db)):
user = await get_current_user(request, None)
if not user:
return RedirectResponse(url="/auth/login", status_code=303)
prop = db.query(Property).filter(Property.id == prop_id).first()
if not prop:
raise HTTPException(status_code=404, detail="Property not found")
if prop.owner_id != user["id"]:
raise HTTPException(status_code=403, detail="Not authorized")
for img in prop.images:
old_path = os.path.join("static", img.image_path)
if os.path.exists(old_path):
os.remove(old_path)
db.delete(prop)
db.commit()
return RedirectResponse(url="/dashboard", status_code=303)
@router.post("/properties/{prop_id}/favorite")
async def toggle_favorite(prop_id: int, request: Request, db: Session = Depends(get_db)):
user = await get_current_user(request, None)
if not user:
return RedirectResponse(url="/auth/login", status_code=303)
existing = db.query(Favorite).filter(
Favorite.user_id == user["id"],
Favorite.property_id == prop_id,
).first()
if existing:
db.delete(existing)
else:
db.add(Favorite(user_id=user["id"], property_id=prop_id))
db.commit()
return RedirectResponse(url=f"/properties/{prop_id}", status_code=303)
@router.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request, db: Session = Depends(get_db)):
user = await get_current_user(request, None)
if not user:
return RedirectResponse(url="/auth/login", status_code=303)
my_properties = (
db.query(Property)
.filter(Property.owner_id == user["id"])
.order_by(Property.created_at.desc())
.all()
)
_apply_image(my_properties)
favorite_ids = [
f.property_id
for f in db.query(Favorite).filter(Favorite.user_id == user["id"]).all()
]
favorites = []
if favorite_ids:
favorites = db.query(Property).filter(Property.id.in_(favorite_ids)).all()
_apply_image(favorites)
return templates.TemplateResponse(request, "dashboard/index.html", {
"user": user,
"my_properties": my_properties,
"favorites": favorites,
})