Initial Commit
This commit is contained in:
+368
@@ -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,
|
||||
})
|
||||
Reference in New Issue
Block a user