import math from flask import Blueprint, jsonify, request from pydantic import ValidationError from sqlalchemy import and_, or_ from sqlalchemy.orm import aliased from app.extensions import db from app.models.catalog import Amenity, property_amenity from app.models.location import Neighborhood from app.models.property import Property from app.schemas.property import PaginatedPropertiesOut, PropertyDetailOut, PropertyOut properties_bp = Blueprint("properties", __name__, url_prefix="/api/v1") def _parse_int(value: str | None, default: int | None = None) -> int | None: if value is None: return default try: return int(value) except (ValueError, TypeError): return default def _parse_float(value: str | None, default: float | None = None) -> float | None: if value is None: return default try: return float(value) except (ValueError, TypeError): return default @properties_bp.get("/properties") def list_properties(): args = request.args # ── Featured shortcut (homepage) ──────────────────────────────────────── if args.get("featured", "").lower() == "true": from app.models.homepage import HomepageConfig config = HomepageConfig.query.first() limit = config.featured_properties_limit if config else 6 props = ( Property.query.filter_by(is_active=True, is_featured=True) .order_by(Property.created_at.desc()) .limit(limit) .all() ) return jsonify( [PropertyOut.model_validate(p).model_dump(mode="json") for p in props] ) # ── Base query ─────────────────────────────────────────────────────────── query = Property.query.filter_by(is_active=True) # Listing type (venda | aluguel) listing_type = args.get("listing_type", "").lower() if listing_type in ("venda", "aluguel"): query = query.filter(Property.type == listing_type) # Subtype — supports subtype_ids (comma-sep) or legacy subtype_id subtype_ids_raw = args.get("subtype_ids", "") if subtype_ids_raw: try: subtype_ids_list = [int(x) for x in subtype_ids_raw.split(",") if x.strip()] except ValueError: subtype_ids_list = [] if subtype_ids_list: query = query.filter(Property.subtype_id.in_(subtype_ids_list)) else: subtype_id = _parse_int(args.get("subtype_id")) if subtype_id is not None: query = query.filter(Property.subtype_id == subtype_id) # City city_id = _parse_int(args.get("city_id")) if city_id is not None: query = query.filter(Property.city_id == city_id) # Neighborhood — supports neighborhood_ids (comma-sep) or legacy neighborhood_id neighborhood_ids_raw = args.get("neighborhood_ids", "") if neighborhood_ids_raw: try: neighborhood_ids_list = [int(x) for x in neighborhood_ids_raw.split(",") if x.strip()] except ValueError: neighborhood_ids_list = [] if neighborhood_ids_list: query = query.filter(Property.neighborhood_id.in_(neighborhood_ids_list)) else: neighborhood_id = _parse_int(args.get("neighborhood_id")) if neighborhood_id is not None: query = query.filter(Property.neighborhood_id == neighborhood_id) # Imobiliária imobiliaria_id = _parse_int(args.get("imobiliaria_id")) if imobiliaria_id is not None: query = query.filter(Property.imobiliaria_id == imobiliaria_id) # Price range price_min = _parse_float(args.get("price_min")) price_max = _parse_float(args.get("price_max")) include_condo = args.get("include_condo", "").lower() == "true" if price_min is not None or price_max is not None: if include_condo: # Effective price = price + coalesce(condo_fee, 0) from sqlalchemy import func effective = Property.price + func.coalesce(Property.condo_fee, 0) else: effective = Property.price if price_min is not None: query = query.filter(effective >= price_min) if price_max is not None: query = query.filter(effective <= price_max) # Bedrooms bedrooms_min = _parse_int(args.get("bedrooms_min")) bedrooms_max = _parse_int(args.get("bedrooms_max")) if bedrooms_min is not None: query = query.filter(Property.bedrooms >= bedrooms_min) if bedrooms_max is not None: query = query.filter(Property.bedrooms <= bedrooms_max) # Bathrooms bathrooms_min = _parse_int(args.get("bathrooms_min")) bathrooms_max = _parse_int(args.get("bathrooms_max")) if bathrooms_min is not None: query = query.filter(Property.bathrooms >= bathrooms_min) if bathrooms_max is not None: query = query.filter(Property.bathrooms <= bathrooms_max) # Parking spots parking_min = _parse_int(args.get("parking_min")) parking_max = _parse_int(args.get("parking_max")) if parking_min is not None: query = query.filter(Property.parking_spots >= parking_min) if parking_max is not None: query = query.filter(Property.parking_spots <= parking_max) # Area m² area_min = _parse_int(args.get("area_min")) area_max = _parse_int(args.get("area_max")) if area_min is not None: query = query.filter(Property.area_m2 >= area_min) if area_max is not None: query = query.filter(Property.area_m2 <= area_max) # Amenities (AND logic — property must have ALL selected amenities) amenity_ids_raw = args.get("amenity_ids", "") if amenity_ids_raw: try: amenity_ids = [int(x) for x in amenity_ids_raw.split(",") if x.strip()] except ValueError: amenity_ids = [] for aid in amenity_ids: query = query.filter( Property.id.in_( property_amenity.select() .where(property_amenity.c.amenity_id == aid) .with_only_columns(property_amenity.c.property_id) ) ) # ── Text search ────────────────────────────────────────────────────────── q_raw = args.get("q", "").strip()[:200] if q_raw: pattern = f"%{q_raw}%" NeighborhoodAlias = aliased(Neighborhood) query = ( query .outerjoin(NeighborhoodAlias, Property.neighborhood_id == NeighborhoodAlias.id) .filter( or_( Property.title.ilike(pattern), Property.address.ilike(pattern), Property.code.ilike(pattern), NeighborhoodAlias.name.ilike(pattern), ) ) ) # ── Sort ───────────────────────────────────────────────────────────────── _sort_map = { "price_asc": Property.price.asc(), "price_desc": Property.price.desc(), "area_desc": Property.area_m2.desc(), "newest": Property.created_at.desc(), "relevance": Property.created_at.desc(), } sort_key = args.get("sort", "relevance") sort_order = _sort_map.get(sort_key, Property.created_at.desc()) # ── Pagination ─────────────────────────────────────────────────────────── page = max(1, _parse_int(args.get("page"), 1)) per_page = min(48, max(1, _parse_int(args.get("per_page"), 24))) total = query.count() pages = math.ceil(total / per_page) if total > 0 else 1 props = ( query.order_by(sort_order) .offset((page - 1) * per_page) .limit(per_page) .all() ) result = PaginatedPropertiesOut( items=[PropertyOut.model_validate(p) for p in props], total=total, page=page, per_page=per_page, pages=pages, ) return jsonify(result.model_dump(mode="json")) @properties_bp.get("/properties/") def get_property(slug: str): prop = Property.query.filter_by(slug=slug, is_active=True).first() if prop is None: return jsonify({"error": "Im\u00f3vel n\u00e3o encontrado"}), 404 return jsonify(PropertyDetailOut.model_validate(prop).model_dump(mode="json")) @properties_bp.post("/properties//contact") def contact_property(slug: str): from app.models.lead import ContactLead from app.schemas.lead import ContactLeadIn, ContactLeadCreatedOut prop = Property.query.filter_by(slug=slug, is_active=True).first() if prop is None: return jsonify({"error": "Im\u00f3vel n\u00e3o encontrado"}), 404 data = request.get_json(silent=True) or {} try: lead_in = ContactLeadIn.model_validate(data) except ValidationError as exc: return jsonify({"error": "Dados inv\u00e1lidos", "details": exc.errors()}), 422 lead = ContactLead( property_id=prop.id, name=lead_in.name, email=lead_in.email, phone=lead_in.phone, message=lead_in.message, ) db.session.add(lead) db.session.commit() return ( jsonify( ContactLeadCreatedOut( id=lead.id, message="Mensagem enviada com sucesso!" ).model_dump() ), 201, )