sass-imobiliaria/backend/app/routes/admin.py

1007 lines
32 KiB
Python

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/<property_id>")
@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/<property_id>")
@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/<int:city_id>")
@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/<int:city_id>")
@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/<int:nb_id>")
@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/<int:nb_id>")
@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/<int:amenity_id>")
@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/<filename>")
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/<user_id>")
@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/<user_id>")
@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/<boleto_id>")
@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/<boleto_id>")
@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/<visit_id>")
@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/<visit_id>")
@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/<fav_id>")
@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/<visit_id>/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)),
}
)