1007 lines
32 KiB
Python
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)),
|
|
}
|
|
)
|