import os import uuid as _uuid import re import bcrypt from datetime import datetime, date from decimal import Decimal from typing import Optional from flask import Blueprint, request, jsonify, current_app, send_from_directory from werkzeug.utils import secure_filename from pydantic import BaseModel, ValidationError from app.extensions import db from app.models.boleto import Boleto from app.models.visit_request import VisitRequest from app.models.user import ClientUser from app.models.saved_property import SavedProperty from app.models.property import Property from app.models.location import City, Neighborhood from app.models.catalog import Amenity from app.models.lead import ContactLead from app.schemas.client_area import BoletoOut, BoletoCreateIn, VisitStatusIn from app.utils.auth import require_admin admin_bp = Blueprint("admin", __name__) _ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "webp", "gif"} _MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB def _allowed_file(filename: str) -> bool: return "." in filename and filename.rsplit(".", 1)[1].lower() in _ALLOWED_EXTENSIONS # ─── Schemas internos ──────────────────────────────────────────────────────── class ClientUserOut(BaseModel): id: str name: str email: str role: str created_at: datetime phone: Optional[str] = None whatsapp: Optional[str] = None cpf: Optional[str] = None birth_date: Optional[date] = None address_street: Optional[str] = None address_number: Optional[str] = None address_complement: Optional[str] = None address_neighborhood: Optional[str] = None address_city: Optional[str] = None address_state: Optional[str] = None address_zip: Optional[str] = None notes: Optional[str] = None model_config = {"from_attributes": True} class ClientUserCreateIn(BaseModel): name: str email: str password: str role: str = "client" phone: Optional[str] = None whatsapp: Optional[str] = None cpf: Optional[str] = None birth_date: Optional[date] = None address_street: Optional[str] = None address_number: Optional[str] = None address_complement: Optional[str] = None address_neighborhood: Optional[str] = None address_city: Optional[str] = None address_state: Optional[str] = None address_zip: Optional[str] = None notes: Optional[str] = None class ClientUserUpdateIn(BaseModel): name: Optional[str] = None email: Optional[str] = None role: Optional[str] = None phone: Optional[str] = None whatsapp: Optional[str] = None cpf: Optional[str] = None birth_date: Optional[date] = None address_street: Optional[str] = None address_number: Optional[str] = None address_complement: Optional[str] = None address_neighborhood: Optional[str] = None address_city: Optional[str] = None address_state: Optional[str] = None address_zip: Optional[str] = None notes: Optional[str] = None class VisitCreateIn(BaseModel): user_id: Optional[str] = None property_id: Optional[str] = None message: str status: str = "pending" scheduled_at: Optional[datetime] = None class BoletoUpdateIn(BaseModel): user_id: Optional[str] = None property_id: Optional[str] = None description: Optional[str] = None amount: Optional[Decimal] = None due_date: Optional[date] = None url: Optional[str] = None status: Optional[str] = None class PhotoIn(BaseModel): url: str alt_text: str = "" display_order: int = 0 class PropertyCreateIn(BaseModel): title: str code: Optional[str] = None address: Optional[str] = None city_id: Optional[int] = None neighborhood_id: Optional[int] = None price: Decimal condo_fee: Optional[Decimal] = None iptu_anual: Optional[Decimal] = None type: str = "venda" bedrooms: int = 0 bathrooms: int = 0 parking_spots: int = 0 parking_spots_covered: int = 0 area_m2: int = 0 description: Optional[str] = None is_active: bool = True is_featured: bool = False photos: list[PhotoIn] = [] amenity_ids: list[int] = [] class PhotoAdminOut(BaseModel): id: Optional[int] = None url: str alt_text: str = "" display_order: int = 0 class PropertyAdminOut(BaseModel): id: str title: str code: Optional[str] = None address: Optional[str] = None city_id: Optional[int] = None neighborhood_id: Optional[int] = None city_name: Optional[str] = None neighborhood_name: Optional[str] = None price: Decimal condo_fee: Optional[Decimal] = None iptu_anual: Optional[Decimal] = None type: str bedrooms: int bathrooms: int parking_spots: int parking_spots_covered: int = 0 area_m2: int description: Optional[str] = None is_active: bool is_featured: bool photos: list[PhotoAdminOut] = [] amenity_ids: list[int] = [] @classmethod def from_prop(cls, p: Property) -> "PropertyAdminOut": return cls( id=str(p.id), title=p.title, code=p.code, address=p.address, city_id=p.city_id, neighborhood_id=p.neighborhood_id, city_name=p.city.name if p.city else None, neighborhood_name=p.neighborhood.name if p.neighborhood else None, price=p.price, condo_fee=p.condo_fee, iptu_anual=p.iptu_anual, type=p.type, bedrooms=p.bedrooms, bathrooms=p.bathrooms, parking_spots=p.parking_spots, parking_spots_covered=p.parking_spots_covered, area_m2=p.area_m2, description=p.description, is_active=p.is_active, is_featured=p.is_featured, photos=[ PhotoAdminOut( id=ph.id, url=ph.url, alt_text=ph.alt_text, display_order=ph.display_order, ) for ph in p.photos ], amenity_ids=[a.id for a in p.amenities], ) # ─── Imóveis ───────────────────────────────────────────────────────────────── @admin_bp.get("/properties") @require_admin def admin_list_properties(): q = request.args.get("q", "").strip() city_id = request.args.get("city_id", type=int) neighborhood_id = request.args.get("neighborhood_id", type=int) try: page = max(1, int(request.args.get("page", 1))) per_page = min(50, max(1, int(request.args.get("per_page", 12)))) except (ValueError, TypeError): page, per_page = 1, 12 query = Property.query if q: query = query.filter( db.or_( Property.title.ilike(f"%{q}%"), Property.code.ilike(f"%{q}%"), ) ) if city_id: query = query.filter(Property.city_id == city_id) if neighborhood_id: query = query.filter(Property.neighborhood_id == neighborhood_id) total = query.count() props = ( query.order_by(Property.created_at.desc()) .offset((page - 1) * per_page) .limit(per_page) .all() ) return ( jsonify( { "items": [ PropertyAdminOut.from_prop(p).model_dump(mode="json") for p in props ], "total": total, "page": page, "per_page": per_page, "pages": max(1, (total + per_page - 1) // per_page), } ), 200, ) @admin_bp.post("/properties") @require_admin def admin_create_property(): try: data = PropertyCreateIn.model_validate(request.get_json() or {}) except ValidationError as e: return jsonify({"error": e.errors(include_url=False)}), 422 slug_base = re.sub(r"[^a-z0-9]+", "-", data.title.lower()).strip("-") slug = slug_base n = 1 while Property.query.filter_by(slug=slug).first(): slug = f"{slug_base}-{n}" n += 1 from app.models.property import PropertyPhoto prop = Property( title=data.title, slug=slug, address=data.address, price=data.price, condo_fee=data.condo_fee, iptu_anual=data.iptu_anual, type=data.type, bedrooms=data.bedrooms, bathrooms=data.bathrooms, parking_spots=data.parking_spots, parking_spots_covered=data.parking_spots_covered, area_m2=data.area_m2, description=data.description, is_active=data.is_active, is_featured=data.is_featured, code=data.code if data.code else None, city_id=data.city_id, neighborhood_id=data.neighborhood_id, ) for i, ph in enumerate(data.photos): prop.photos.append( PropertyPhoto( url=ph.url, alt_text=ph.alt_text, display_order=ph.display_order if ph.display_order else i, ) ) if data.amenity_ids: amenities = Amenity.query.filter(Amenity.id.in_(data.amenity_ids)).all() prop.amenities.extend(amenities) db.session.add(prop) db.session.commit() db.session.refresh(prop) return jsonify(PropertyAdminOut.from_prop(prop).model_dump(mode="json")), 201 @admin_bp.put("/properties/") @require_admin def admin_update_property(property_id: str): try: prop_uuid = _uuid.UUID(property_id) except ValueError: return jsonify({"error": "ID inválido"}), 422 prop = db.session.get(Property, prop_uuid) if not prop: return jsonify({"error": "Imóvel não encontrado"}), 404 body = request.get_json() or {} _SCALAR_FIELDS = ( "title", "address", "description", "type", "is_active", "is_featured", "price", "condo_fee", "iptu_anual", "bedrooms", "bathrooms", "parking_spots", "parking_spots_covered", "area_m2", "city_id", "neighborhood_id", ) for field in _SCALAR_FIELDS: if field in body: setattr(prop, field, body[field]) # code: tratar string vazia como NULL if "code" in body: prop.code = body["code"] if body["code"] else None # amenity_ids: substituir conjunto de amenidades if "amenity_ids" in body: amenities = Amenity.query.filter(Amenity.id.in_(body["amenity_ids"])).all() prop.amenities = amenities # photos: substituir toda a lista se a chave for enviada if "photos" in body: from app.models.property import PropertyPhoto for ph in list(prop.photos): db.session.delete(ph) for i, ph_data in enumerate(body["photos"]): prop.photos.append( PropertyPhoto( url=ph_data["url"], alt_text=ph_data.get("alt_text", ""), display_order=ph_data.get("display_order", i), ) ) db.session.commit() db.session.refresh(prop) return jsonify(PropertyAdminOut.from_prop(prop).model_dump(mode="json")), 200 @admin_bp.delete("/properties/") @require_admin def admin_delete_property(property_id: str): try: prop_uuid = _uuid.UUID(property_id) except ValueError: return jsonify({"error": "ID inválido"}), 422 prop = db.session.get(Property, prop_uuid) if not prop: return jsonify({"error": "Imóvel não encontrado"}), 404 db.session.delete(prop) db.session.commit() return "", 204 @admin_bp.get("/cities") @require_admin def admin_list_cities(): cities = City.query.order_by(City.name).all() return ( jsonify([{"id": c.id, "name": c.name, "state": c.state} for c in cities]), 200, ) @admin_bp.get("/neighborhoods") @require_admin def admin_list_neighborhoods(): city_id = request.args.get("city_id", type=int) query = Neighborhood.query.order_by(Neighborhood.name) if city_id: query = query.filter(Neighborhood.city_id == city_id) neighborhoods = query.all() return ( jsonify( [{"id": n.id, "name": n.name, "city_id": n.city_id} for n in neighborhoods] ), 200, ) @admin_bp.post("/cities") @require_admin def admin_create_city(): body = request.get_json() or {} name = (body.get("name") or "").strip() state = (body.get("state") or "").strip().upper() if not name or not state: return jsonify({"error": "name e state são obrigatórios"}), 422 slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") # garante slug único base = slug n = 1 while City.query.filter_by(slug=slug).first(): slug = f"{base}-{n}" n += 1 city = City(name=name, slug=slug, state=state) db.session.add(city) db.session.commit() return ( jsonify( {"id": city.id, "name": city.name, "state": city.state, "slug": city.slug} ), 201, ) @admin_bp.put("/cities/") @require_admin def admin_update_city(city_id: int): city = db.session.get(City, city_id) if not city: return jsonify({"error": "Cidade não encontrada"}), 404 body = request.get_json() or {} if "name" in body and body["name"]: city.name = body["name"].strip() city.slug = re.sub(r"[^a-z0-9]+", "-", city.name.lower()).strip("-") if "state" in body and body["state"]: city.state = body["state"].strip().upper() db.session.commit() return ( jsonify( {"id": city.id, "name": city.name, "state": city.state, "slug": city.slug} ), 200, ) @admin_bp.delete("/cities/") @require_admin def admin_delete_city(city_id: int): city = db.session.get(City, city_id) if not city: return jsonify({"error": "Cidade não encontrada"}), 404 db.session.delete(city) db.session.commit() return "", 204 @admin_bp.post("/neighborhoods") @require_admin def admin_create_neighborhood(): body = request.get_json() or {} name = (body.get("name") or "").strip() city_id = body.get("city_id") if not name or not city_id: return jsonify({"error": "name e city_id são obrigatórios"}), 422 if not db.session.get(City, city_id): return jsonify({"error": "Cidade não encontrada"}), 404 slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") base = slug n = 1 while Neighborhood.query.filter_by(slug=slug, city_id=city_id).first(): slug = f"{base}-{n}" n += 1 nb = Neighborhood(name=name, slug=slug, city_id=city_id) db.session.add(nb) db.session.commit() return ( jsonify({"id": nb.id, "name": nb.name, "city_id": nb.city_id, "slug": nb.slug}), 201, ) @admin_bp.put("/neighborhoods/") @require_admin def admin_update_neighborhood(nb_id: int): nb = db.session.get(Neighborhood, nb_id) if not nb: return jsonify({"error": "Bairro não encontrado"}), 404 body = request.get_json() or {} if "name" in body and body["name"]: nb.name = body["name"].strip() nb.slug = re.sub(r"[^a-z0-9]+", "-", nb.name.lower()).strip("-") db.session.commit() return ( jsonify({"id": nb.id, "name": nb.name, "city_id": nb.city_id, "slug": nb.slug}), 200, ) @admin_bp.delete("/neighborhoods/") @require_admin def admin_delete_neighborhood(nb_id: int): nb = db.session.get(Neighborhood, nb_id) if not nb: return jsonify({"error": "Bairro não encontrado"}), 404 db.session.delete(nb) db.session.commit() return "", 204 # ─── Amenidades ─────────────────────────────────────────────────────────────── @admin_bp.get("/amenities") @require_admin def admin_list_amenities(): amenities = Amenity.query.order_by(Amenity.group, Amenity.name).all() return ( jsonify([{"id": a.id, "name": a.name, "group": a.group} for a in amenities]), 200, ) @admin_bp.post("/amenities") @require_admin def admin_create_amenity(): body = request.get_json() or {} name = (body.get("name") or "").strip() group = (body.get("group") or "").strip() valid_groups = {"caracteristica", "lazer", "condominio", "seguranca"} if not name or group not in valid_groups: return ( jsonify( {"error": f"name e group ({', '.join(valid_groups)}) são obrigatórios"} ), 422, ) amenity = Amenity(name=name, group=group) db.session.add(amenity) db.session.commit() return ( jsonify({"id": amenity.id, "name": amenity.name, "group": amenity.group}), 201, ) @admin_bp.delete("/amenities/") @require_admin def admin_delete_amenity(amenity_id: int): amenity = db.session.get(Amenity, amenity_id) if not amenity: return jsonify({"error": "Amenidade não encontrada"}), 404 db.session.delete(amenity) db.session.commit() return "", 204 # ─── Upload de fotos ────────────────────────────────────────────────────────── @admin_bp.post("/upload/photo") @require_admin def admin_upload_photo(): if "file" not in request.files: return jsonify({"error": "Campo 'file' obrigatório"}), 422 file = request.files["file"] if not file or not file.filename: return jsonify({"error": "Arquivo vazio"}), 422 if not _allowed_file(file.filename): return jsonify({"error": "Tipo de arquivo não permitido"}), 422 # verificar tamanho file.seek(0, 2) size = file.tell() file.seek(0) if size > _MAX_FILE_SIZE: return jsonify({"error": "Arquivo muito grande (máx 5 MB)"}), 422 ext = file.filename.rsplit(".", 1)[1].lower() unique_name = f"{_uuid.uuid4().hex}.{ext}" safe_name = secure_filename(unique_name) upload_folder = current_app.config.get("UPLOAD_FOLDER", "uploads") os.makedirs(upload_folder, exist_ok=True) file.save(os.path.join(upload_folder, safe_name)) url = f"/api/v1/admin/upload/{safe_name}" return jsonify({"url": url, "filename": safe_name}), 201 @admin_bp.get("/upload/") def admin_serve_upload(filename: str): # Não requer autenticação — imagens têm UUID no nome (não são adivinháveis) safe = secure_filename(filename) upload_folder = current_app.config.get("UPLOAD_FOLDER", "uploads") return send_from_directory(upload_folder, safe) # ─── Próximo código de imóvel ───────────────────────────────────────────────── @admin_bp.get("/next-property-code") @require_admin def admin_next_property_code(): last = ( db.session.query(Property.code) .filter(Property.code.like("IM-%")) .order_by(Property.code.desc()) .first() ) if last and last[0]: try: num = int(last[0].split("-")[1]) + 1 except (IndexError, ValueError): num = 1 else: num = 1 return jsonify({"code": f"IM-{num:04d}"}), 200 # ─── Clientes ───────────────────────────────────────────────────────────────── @admin_bp.get("/client-users") @require_admin def list_client_users(): users = ClientUser.query.order_by(ClientUser.created_at.desc()).all() return ( jsonify( [ClientUserOut.model_validate(u).model_dump(mode="json") for u in users] ), 200, ) @admin_bp.post("/client-users") @require_admin def create_client_user(): try: data = ClientUserCreateIn.model_validate(request.get_json() or {}) except ValidationError as e: return jsonify({"error": e.errors(include_url=False)}), 422 if ClientUser.query.filter_by(email=data.email).first(): return jsonify({"error": "E-mail já cadastrado"}), 409 if len(data.password) < 8: return jsonify({"error": "Senha deve ter pelo menos 8 caracteres"}), 422 pwd_hash = bcrypt.hashpw(data.password.encode(), bcrypt.gensalt()).decode() user = ClientUser( name=data.name, email=data.email, password_hash=pwd_hash, role=data.role, phone=data.phone, whatsapp=data.whatsapp, cpf=data.cpf, birth_date=data.birth_date, address_street=data.address_street, address_number=data.address_number, address_complement=data.address_complement, address_neighborhood=data.address_neighborhood, address_city=data.address_city, address_state=data.address_state, address_zip=data.address_zip, notes=data.notes, ) db.session.add(user) db.session.commit() return jsonify(ClientUserOut.model_validate(user).model_dump(mode="json")), 201 @admin_bp.put("/client-users/") @require_admin def update_client_user(user_id: str): user = db.session.get(ClientUser, user_id) if not user: return jsonify({"error": "Cliente não encontrado"}), 404 try: data = ClientUserUpdateIn.model_validate(request.get_json() or {}) except ValidationError as e: return jsonify({"error": e.errors(include_url=False)}), 422 if data.name is not None: user.name = data.name if data.email is not None: user.email = data.email if data.role is not None: user.role = data.role for field in ( "phone", "whatsapp", "cpf", "birth_date", "address_street", "address_number", "address_complement", "address_neighborhood", "address_city", "address_state", "address_zip", "notes", ): val = getattr(data, field) if val is not None: setattr(user, field, val) db.session.commit() return jsonify(ClientUserOut.model_validate(user).model_dump(mode="json")), 200 @admin_bp.delete("/client-users/") @require_admin def delete_client_user(user_id: str): user = db.session.get(ClientUser, user_id) if not user: return jsonify({"error": "Cliente não encontrado"}), 404 db.session.delete(user) db.session.commit() return "", 204 # ─── Boletos ───────────────────────────────────────────────────────────────── @admin_bp.get("/boletos") @require_admin def list_boletos(): boletos = Boleto.query.order_by(Boleto.created_at.desc()).all() result = [] for b in boletos: item = BoletoOut.model_validate(b).model_dump(mode="json") item["user_name"] = b.user.name if b.user else None item["property_title"] = b.property.title if b.property else None result.append(item) return jsonify(result), 200 @admin_bp.post("/boletos") @require_admin def create_boleto(): try: data = BoletoCreateIn.model_validate(request.get_json() or {}) except ValidationError as e: return jsonify({"error": e.errors(include_url=False)}), 422 user = db.session.get(ClientUser, data.user_id) if not user: return jsonify({"error": "Usuário não encontrado"}), 404 boleto = Boleto( user_id=data.user_id, property_id=data.property_id, description=data.description, amount=data.amount, due_date=data.due_date, url=data.url, ) db.session.add(boleto) db.session.commit() return jsonify(BoletoOut.model_validate(boleto).model_dump(mode="json")), 201 @admin_bp.put("/boletos/") @require_admin def update_boleto(boleto_id: str): boleto = db.session.get(Boleto, boleto_id) if not boleto: return jsonify({"error": "Boleto não encontrado"}), 404 try: data = BoletoUpdateIn.model_validate(request.get_json() or {}) except ValidationError as e: return jsonify({"error": e.errors(include_url=False)}), 422 if data.user_id is not None: boleto.user_id = data.user_id if data.property_id is not None: boleto.property_id = data.property_id if data.description is not None: boleto.description = data.description if data.amount is not None: boleto.amount = data.amount if data.due_date is not None: boleto.due_date = data.due_date if data.url is not None: boleto.url = data.url if data.status is not None: boleto.status = data.status db.session.commit() return jsonify(BoletoOut.model_validate(boleto).model_dump(mode="json")), 200 @admin_bp.delete("/boletos/") @require_admin def delete_boleto(boleto_id: str): boleto = db.session.get(Boleto, boleto_id) if not boleto: return jsonify({"error": "Boleto não encontrado"}), 404 db.session.delete(boleto) db.session.commit() return "", 204 # ─── Visitas ───────────────────────────────────────────────────────────────── @admin_bp.get("/visitas") @require_admin def list_visitas(): visits = VisitRequest.query.order_by(VisitRequest.created_at.desc()).all() result = [] for v in visits: result.append( { "id": v.id, "user_id": v.user_id, "property_id": str(v.property_id) if v.property_id else None, "message": v.message, "status": v.status, "scheduled_at": v.scheduled_at.isoformat() if v.scheduled_at else None, "created_at": v.created_at.isoformat(), "user_name": v.user.name if v.user else None, "property_title": v.property.title if v.property else None, } ) return jsonify(result), 200 @admin_bp.post("/visitas") @require_admin def create_visita(): try: data = VisitCreateIn.model_validate(request.get_json() or {}) except ValidationError as e: return jsonify({"error": e.errors(include_url=False)}), 422 visit = VisitRequest( user_id=data.user_id, property_id=_uuid.UUID(data.property_id) if data.property_id else None, message=data.message, status=data.status, scheduled_at=data.scheduled_at, ) db.session.add(visit) db.session.commit() return ( jsonify( { "id": visit.id, "user_id": visit.user_id, "property_id": str(visit.property_id) if visit.property_id else None, "message": visit.message, "status": visit.status, "scheduled_at": ( visit.scheduled_at.isoformat() if visit.scheduled_at else None ), "created_at": visit.created_at.isoformat(), } ), 201, ) @admin_bp.put("/visitas/") @require_admin def update_visita(visit_id: str): visit = db.session.get(VisitRequest, visit_id) if not visit: return jsonify({"error": "Visita não encontrada"}), 404 try: data = VisitStatusIn.model_validate(request.get_json() or {}) except ValidationError as e: return jsonify({"error": e.errors(include_url=False)}), 422 visit.status = data.status if data.scheduled_at: visit.scheduled_at = data.scheduled_at db.session.commit() return ( jsonify( { "id": visit.id, "status": visit.status, "scheduled_at": ( visit.scheduled_at.isoformat() if visit.scheduled_at else None ), } ), 200, ) @admin_bp.delete("/visitas/") @require_admin def delete_visita(visit_id: str): visit = db.session.get(VisitRequest, visit_id) if not visit: return jsonify({"error": "Visita não encontrada"}), 404 db.session.delete(visit) db.session.commit() return "", 204 # ─── Favoritos ─────────────────────────────────────────────────────────────── @admin_bp.get("/favoritos") @require_admin def list_favoritos(): favs = SavedProperty.query.order_by(SavedProperty.created_at.desc()).all() result = [] for f in favs: result.append( { "id": f.id, "user_id": f.user_id, "property_id": str(f.property_id) if f.property_id else None, "created_at": f.created_at.isoformat(), "user_name": f.user.name if f.user else None, "property_title": f.property.title if f.property else None, } ) return jsonify(result), 200 @admin_bp.delete("/favoritos/") @require_admin def delete_favorito(fav_id: str): fav = db.session.get(SavedProperty, fav_id) if not fav: return jsonify({"error": "Favorito não encontrado"}), 404 db.session.delete(fav) db.session.commit() return "", 204 # ─── Endpoint legado ───────────────────────────────────────────────────────── @admin_bp.put("/visits//status") @require_admin def update_visit_status(visit_id: str): try: data = VisitStatusIn.model_validate(request.get_json() or {}) except ValidationError as e: return jsonify({"error": e.errors(include_url=False)}), 422 visit = db.session.get(VisitRequest, visit_id) if not visit: return jsonify({"error": "Visita não encontrada"}), 404 visit.status = data.status if data.scheduled_at: visit.scheduled_at = data.scheduled_at db.session.commit() return ( jsonify( { "id": visit.id, "status": visit.status, "scheduled_at": ( visit.scheduled_at.isoformat() if visit.scheduled_at else None ), } ), 200, ) # ─── Leads de contato ──────────────────────────────────────────────────────── @admin_bp.get("/leads") @require_admin def list_leads(): page = max(1, request.args.get("page", 1, type=int)) per_page = min(100, max(1, request.args.get("per_page", 20, type=int))) property_id = request.args.get("property_id") q = db.select(ContactLead).order_by(ContactLead.created_at.desc()) if property_id: q = q.where(ContactLead.property_id == property_id) total = db.session.scalar( db.select(db.func.count()).select_from(q.subquery()) ) leads = db.session.scalars( q.limit(per_page).offset((page - 1) * per_page) ).all() return jsonify( { "items": [ { "id": lead.id, "property_id": str(lead.property_id) if lead.property_id else None, "name": lead.name, "email": lead.email, "phone": lead.phone, "message": lead.message, "created_at": lead.created_at.isoformat(), } for lead in leads ], "total": total, "page": page, "per_page": per_page, "pages": max(1, -(-total // per_page)), } )