feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
- feat(025): favoritos locais com FavoritesContext, HeartButton, PublicFavoritesPage
- feat(026): central de contatos admin (leads/contatos unificados)
- feat(027): configuração da página de contato via admin
- feat(028): trabalhe conosco - candidaturas com upload e admin
- feat(029): UX área do cliente - visitas, comparação, perfil
- feat(030): navbar UX - menu mobile, ThemeToggle, useFavorites
- feat(031): hero light/dark - imagens separadas por tema, upload, preview, seed
- feat(032): performance homepage - Promise.all parallel fetches, sessionStorage cache,
preload hero image, loading=lazy nos cards, useInView hook, will-change carrossel,
keyframes em index.css, AgentsCarousel e HomeScrollScene via props
- fix: light mode HomeScrollScene - gradiente, cores de texto, scroll hint
migrations: g1h2i3j4k5l6 (source em leads), h1i2j3k4l5m6 (contact_config),
i1j2k3l4m5n6 (job_applications), j2k3l4m5n6o7 (hero theme images)
This commit is contained in:
parent
6ef5a7a17e
commit
cf5603243c
106 changed files with 11927 additions and 1367 deletions
|
|
@ -38,6 +38,8 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||
from app.models import page_view as _page_view_models # noqa: F401
|
||||
from app.models import agent as _agent_models # noqa: F401
|
||||
from app.models import imobiliaria as _imobiliaria_models # noqa: F401
|
||||
from app.models import contact_config as _contact_config_models # noqa: F401
|
||||
from app.models import job_application as _job_application_models # noqa: F401
|
||||
|
||||
# JWT secret key — raises KeyError if not set (fail-fast on misconfiguration)
|
||||
app.config["JWT_SECRET_KEY"] = os.environ["JWT_SECRET_KEY"]
|
||||
|
|
@ -54,6 +56,8 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||
from app.routes.config import config_bp
|
||||
from app.routes.agents import agents_public_bp, agents_admin_bp
|
||||
from app.routes.version import version_bp
|
||||
from app.routes.contact_config import contact_config_bp
|
||||
from app.routes.jobs import jobs_public_bp, jobs_admin_bp
|
||||
|
||||
app.register_blueprint(homepage_bp)
|
||||
app.register_blueprint(properties_bp)
|
||||
|
|
@ -67,10 +71,14 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||
app.register_blueprint(agents_public_bp)
|
||||
app.register_blueprint(agents_admin_bp)
|
||||
app.register_blueprint(version_bp)
|
||||
app.register_blueprint(contact_config_bp)
|
||||
app.register_blueprint(jobs_public_bp)
|
||||
app.register_blueprint(jobs_admin_bp)
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
from flask import jsonify
|
||||
|
||||
try:
|
||||
db.session.execute(db.text("SELECT 1"))
|
||||
return jsonify({"status": "ok", "db": "ok"}), 200
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ from .saved_property import SavedProperty
|
|||
from .visit_request import VisitRequest
|
||||
from .boleto import Boleto
|
||||
from .page_view import PageView
|
||||
from .contact_config import ContactConfig
|
||||
|
|
|
|||
22
backend/app/models/contact_config.py
Normal file
22
backend/app/models/contact_config.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from app.extensions import db
|
||||
|
||||
|
||||
class ContactConfig(db.Model):
|
||||
__tablename__ = "contact_config"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
address_street = db.Column(db.String(200), nullable=True)
|
||||
address_neighborhood_city = db.Column(db.String(200), nullable=True)
|
||||
address_zip = db.Column(db.String(20), nullable=True)
|
||||
phone = db.Column(db.String(30), nullable=True)
|
||||
email = db.Column(db.String(254), nullable=True)
|
||||
business_hours = db.Column(db.Text, nullable=True)
|
||||
updated_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
server_default=db.func.now(),
|
||||
onupdate=db.func.now(),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ContactConfig id={self.id}>"
|
||||
|
|
@ -11,6 +11,8 @@ class HomepageConfig(db.Model):
|
|||
hero_cta_url = db.Column(db.String(200), nullable=False, default="/imoveis")
|
||||
featured_properties_limit = db.Column(db.Integer, nullable=False, default=6)
|
||||
hero_image_url = db.Column(db.String(512), nullable=True)
|
||||
hero_image_light_url = db.Column(db.String(512), nullable=True)
|
||||
hero_image_dark_url = db.Column(db.String(512), nullable=True)
|
||||
updated_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
|
|
|
|||
27
backend/app/models/job_application.py
Normal file
27
backend/app/models/job_application.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from app.extensions import db
|
||||
|
||||
ROLE_INTEREST_OPTIONS = [
|
||||
"Corretor(a)",
|
||||
"Assistente Administrativo",
|
||||
"Estagiário(a)",
|
||||
"Outro",
|
||||
]
|
||||
|
||||
|
||||
class JobApplication(db.Model):
|
||||
__tablename__ = "job_applications"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
email = db.Column(db.String(254), nullable=False)
|
||||
phone = db.Column(db.String(30), nullable=True)
|
||||
role_interest = db.Column(db.String(100), nullable=False)
|
||||
message = db.Column(db.Text, nullable=False)
|
||||
file_name = db.Column(db.String(255), nullable=True)
|
||||
status = db.Column(db.String(50), nullable=False, default="pending")
|
||||
created_at = db.Column(
|
||||
db.DateTime, nullable=False, server_default=db.func.now()
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<JobApplication id={self.id} email={self.email!r}>"
|
||||
|
|
@ -15,6 +15,10 @@ class ContactLead(db.Model):
|
|||
email = db.Column(db.String(254), nullable=False)
|
||||
phone = db.Column(db.String(20), nullable=True)
|
||||
message = db.Column(db.Text, nullable=False)
|
||||
source = db.Column(
|
||||
db.String(100), nullable=True
|
||||
) # contato | imovel | cadastro_residencia
|
||||
source_detail = db.Column(db.String(255), nullable=True) # ex: título do imóvel
|
||||
created_at = db.Column(
|
||||
db.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
|
|
@ -22,4 +26,4 @@ class ContactLead(db.Model):
|
|||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ContactLead id={self.id} email={self.email!r}>"
|
||||
return f"<ContactLead id={self.id} email={self.email!r} source={self.source!r}>"
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ class PhotoAdminOut(BaseModel):
|
|||
|
||||
class PropertyAdminOut(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
title: str
|
||||
code: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
|
|
@ -172,6 +173,7 @@ class PropertyAdminOut(BaseModel):
|
|||
def from_prop(cls, p: Property) -> "PropertyAdminOut":
|
||||
return cls(
|
||||
id=str(p.id),
|
||||
slug=p.slug,
|
||||
title=p.title,
|
||||
code=p.code,
|
||||
address=p.address,
|
||||
|
|
@ -213,6 +215,8 @@ 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)
|
||||
type_filter = request.args.get("type") # 'venda' | 'aluguel' | None
|
||||
is_active_raw = request.args.get("is_active") # 'true' | 'false' | None
|
||||
try:
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(50, max(1, int(request.args.get("per_page", 12))))
|
||||
|
|
@ -231,6 +235,12 @@ def admin_list_properties():
|
|||
query = query.filter(Property.city_id == city_id)
|
||||
if neighborhood_id:
|
||||
query = query.filter(Property.neighborhood_id == neighborhood_id)
|
||||
if type_filter in ("venda", "aluguel"):
|
||||
query = query.filter(Property.type == type_filter)
|
||||
if is_active_raw == "true":
|
||||
query = query.filter(Property.is_active.is_(True))
|
||||
elif is_active_raw == "false":
|
||||
query = query.filter(Property.is_active.is_(False))
|
||||
|
||||
total = query.count()
|
||||
props = (
|
||||
|
|
@ -973,17 +983,16 @@ 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")
|
||||
source = request.args.get("source")
|
||||
|
||||
q = db.select(ContactLead).order_by(ContactLead.created_at.desc())
|
||||
if property_id:
|
||||
q = q.where(ContactLead.property_id == property_id)
|
||||
if source:
|
||||
q = q.where(ContactLead.source == source)
|
||||
|
||||
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()
|
||||
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(
|
||||
{
|
||||
|
|
@ -995,6 +1004,8 @@ def list_leads():
|
|||
"email": lead.email,
|
||||
"phone": lead.phone,
|
||||
"message": lead.message,
|
||||
"source": lead.source,
|
||||
"source_detail": lead.source_detail,
|
||||
"created_at": lead.created_at.isoformat(),
|
||||
}
|
||||
for lead in leads
|
||||
|
|
@ -1005,3 +1016,46 @@ def list_leads():
|
|||
"pages": max(1, -(-total // per_page)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ─── Contact config ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@admin_bp.get("/contact-config")
|
||||
@require_admin
|
||||
def get_contact_config_admin():
|
||||
from app.models.contact_config import ContactConfig
|
||||
from app.schemas.contact_config import ContactConfigOut
|
||||
|
||||
config = ContactConfig.query.first()
|
||||
if config is None:
|
||||
return jsonify({}), 200
|
||||
return jsonify(ContactConfigOut.model_validate(config).model_dump())
|
||||
|
||||
|
||||
@admin_bp.put("/contact-config")
|
||||
@require_admin
|
||||
def update_contact_config():
|
||||
from app.models.contact_config import ContactConfig
|
||||
from app.schemas.contact_config import ContactConfigIn, ContactConfigOut
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
cfg_in = ContactConfigIn.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return jsonify({"error": "Dados inválidos", "details": exc.errors()}), 422
|
||||
|
||||
config = ContactConfig.query.first()
|
||||
if config is None:
|
||||
config = ContactConfig(id=1)
|
||||
db.session.add(config)
|
||||
|
||||
config.address_street = cfg_in.address_street
|
||||
config.address_neighborhood_city = cfg_in.address_neighborhood_city
|
||||
config.address_zip = cfg_in.address_zip
|
||||
config.phone = cfg_in.phone
|
||||
config.email = cfg_in.email
|
||||
config.business_hours = cfg_in.business_hours
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(ContactConfigOut.model_validate(config).model_dump())
|
||||
|
|
|
|||
|
|
@ -1,17 +1,24 @@
|
|||
import uuid as _uuid
|
||||
import bcrypt
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app import db
|
||||
from app.models.saved_property import SavedProperty
|
||||
from app.models.visit_request import VisitRequest
|
||||
from app.models.boleto import Boleto
|
||||
from app.models.property import Property
|
||||
from app.models.user import ClientUser
|
||||
from app.schemas.client_area import (
|
||||
SavedPropertyOut,
|
||||
PropertyCard,
|
||||
FavoriteIn,
|
||||
VisitRequestOut,
|
||||
BoletoOut,
|
||||
UpdateProfileIn,
|
||||
UpdateProfileOut,
|
||||
UpdatePasswordIn,
|
||||
)
|
||||
from app.utils.auth import require_auth
|
||||
|
||||
|
|
@ -22,13 +29,15 @@ client_bp = Blueprint("client", __name__)
|
|||
@require_auth
|
||||
def get_favorites():
|
||||
saved = (
|
||||
SavedProperty.query.filter_by(user_id=g.current_user_id)
|
||||
SavedProperty.query
|
||||
.filter_by(user_id=g.current_user_id)
|
||||
.options(selectinload(SavedProperty.property).selectinload(Property.photos))
|
||||
.order_by(SavedProperty.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return (
|
||||
jsonify(
|
||||
[SavedPropertyOut.model_validate(s).model_dump(mode="json") for s in saved]
|
||||
[SavedPropertyOut.from_saved(s).model_dump(mode="json") for s in saved]
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
|
@ -58,7 +67,7 @@ def add_favorite():
|
|||
db.session.rollback()
|
||||
return jsonify({"error": "Imóvel já está nos favoritos"}), 409
|
||||
|
||||
return jsonify(SavedPropertyOut.model_validate(saved).model_dump(mode="json")), 201
|
||||
return jsonify(SavedPropertyOut.from_saved(saved).model_dump(mode="json")), 201
|
||||
|
||||
|
||||
@client_bp.delete("/favorites/<property_id>")
|
||||
|
|
@ -107,3 +116,64 @@ def get_boletos():
|
|||
jsonify([BoletoOut.model_validate(b).model_dump(mode="json") for b in boletos]),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@client_bp.patch("/profile")
|
||||
@require_auth
|
||||
def update_profile():
|
||||
try:
|
||||
data = UpdateProfileIn.model_validate(request.get_json() or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": e.errors(include_url=False)}), 422
|
||||
|
||||
user = db.session.get(ClientUser, g.current_user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "Usuário não encontrado"}), 404
|
||||
|
||||
user.name = data.name
|
||||
db.session.commit()
|
||||
return jsonify(UpdateProfileOut.model_validate(user).model_dump(mode="json")), 200
|
||||
|
||||
|
||||
@client_bp.patch("/password")
|
||||
@require_auth
|
||||
def change_password():
|
||||
try:
|
||||
data = UpdatePasswordIn.model_validate(request.get_json() or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": e.errors(include_url=False)}), 422
|
||||
|
||||
user = db.session.get(ClientUser, g.current_user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "Usuário não encontrado"}), 404
|
||||
|
||||
if not bcrypt.checkpw(
|
||||
data.current_password.encode("utf-8"),
|
||||
user.password_hash.encode("utf-8"),
|
||||
):
|
||||
return jsonify({"error": "Senha atual incorreta"}), 400
|
||||
|
||||
user.password_hash = bcrypt.hashpw(
|
||||
data.new_password.encode("utf-8"), bcrypt.gensalt()
|
||||
).decode("utf-8")
|
||||
db.session.commit()
|
||||
return "", 204
|
||||
|
||||
|
||||
@client_bp.patch("/visits/<visit_id>/cancel")
|
||||
@require_auth
|
||||
def cancel_visit(visit_id: str):
|
||||
visit = db.session.get(VisitRequest, visit_id)
|
||||
if not visit:
|
||||
return jsonify({"error": "Visita não encontrada"}), 404
|
||||
|
||||
if str(visit.user_id) != str(g.current_user_id):
|
||||
return jsonify({"error": "Acesso negado"}), 403
|
||||
|
||||
if visit.status != "pending":
|
||||
return jsonify({"error": "Apenas visitas pendentes podem ser canceladas"}), 400
|
||||
|
||||
visit.status = "cancelled"
|
||||
db.session.commit()
|
||||
return jsonify(VisitRequestOut.model_validate(visit).model_dump(mode="json")), 200
|
||||
|
||||
|
|
|
|||
14
backend/app/routes/contact_config.py
Normal file
14
backend/app/routes/contact_config.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from flask import Blueprint, jsonify
|
||||
|
||||
from app.models.contact_config import ContactConfig
|
||||
from app.schemas.contact_config import ContactConfigOut
|
||||
|
||||
contact_config_bp = Blueprint("contact_config", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@contact_config_bp.get("/contact-config")
|
||||
def get_contact_config():
|
||||
config = ContactConfig.query.first()
|
||||
if config is None:
|
||||
return jsonify({}), 200
|
||||
return jsonify(ContactConfigOut.model_validate(config).model_dump())
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
from flask import Blueprint, jsonify
|
||||
from flask import Blueprint, jsonify, request
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.homepage import HomepageConfig
|
||||
from app.schemas.homepage import HomepageConfigOut
|
||||
from app.schemas.homepage import HomepageConfigOut, HomepageHeroImagesIn
|
||||
from app.utils.auth import require_admin
|
||||
|
||||
homepage_bp = Blueprint("homepage", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
|
@ -11,4 +14,33 @@ def get_homepage_config():
|
|||
config = HomepageConfig.query.first()
|
||||
if config is None:
|
||||
return jsonify({"error": "Homepage config not found"}), 404
|
||||
return jsonify(HomepageConfigOut.model_validate(config).model_dump())
|
||||
return jsonify(HomepageConfigOut.model_validate(config).model_dump(mode="json"))
|
||||
|
||||
|
||||
@homepage_bp.put("/admin/homepage-config")
|
||||
@require_admin
|
||||
def update_homepage_hero_images():
|
||||
try:
|
||||
data = HomepageHeroImagesIn.model_validate(request.get_json() or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": e.errors(include_url=False)}), 422
|
||||
|
||||
config = HomepageConfig.query.first()
|
||||
if config is None:
|
||||
config = HomepageConfig(
|
||||
hero_headline="Encontre o imóvel dos seus sonhos",
|
||||
hero_subheadline="Os melhores imóveis para comprar ou alugar na sua região",
|
||||
hero_cta_label="Ver Imóveis",
|
||||
hero_cta_url="/imoveis",
|
||||
featured_properties_limit=6,
|
||||
)
|
||||
db.session.add(config)
|
||||
|
||||
config.hero_image_url = data.hero_image_url
|
||||
config.hero_image_light_url = data.hero_image_light_url
|
||||
config.hero_image_dark_url = data.hero_image_dark_url
|
||||
|
||||
db.session.commit()
|
||||
db.session.refresh(config)
|
||||
|
||||
return jsonify(HomepageConfigOut.model_validate(config).model_dump(mode="json")), 200
|
||||
|
|
|
|||
56
backend/app/routes/jobs.py
Normal file
56
backend/app/routes/jobs.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import json as _json
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.job_application import JobApplication
|
||||
from app.schemas.job_application import JobApplicationIn, JobApplicationOut
|
||||
from app.utils.auth import require_admin
|
||||
|
||||
jobs_public_bp = Blueprint("jobs_public", __name__, url_prefix="/api/v1")
|
||||
jobs_admin_bp = Blueprint("jobs_admin", __name__, url_prefix="/api/v1/admin")
|
||||
|
||||
|
||||
@jobs_public_bp.route("/jobs/apply", methods=["POST"])
|
||||
def apply():
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
validated = JobApplicationIn.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return jsonify({"error": "Dados inválidos", "details": _json.loads(exc.json())}), 422
|
||||
|
||||
application = JobApplication(
|
||||
name=validated.name,
|
||||
email=validated.email,
|
||||
phone=validated.phone,
|
||||
role_interest=validated.role_interest,
|
||||
message=validated.message,
|
||||
file_name=validated.file_name,
|
||||
)
|
||||
db.session.add(application)
|
||||
db.session.commit()
|
||||
return jsonify({"message": "Candidatura recebida com sucesso"}), 201
|
||||
|
||||
|
||||
@jobs_admin_bp.route("/jobs", methods=["GET"])
|
||||
@require_admin
|
||||
def list_applications():
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = min(request.args.get("per_page", 20, type=int), 100)
|
||||
|
||||
pagination = (
|
||||
JobApplication.query
|
||||
.order_by(JobApplication.created_at.desc())
|
||||
.paginate(page=page, per_page=per_page, error_out=False)
|
||||
)
|
||||
|
||||
items = [JobApplicationOut.model_validate(item).model_dump(mode="json") for item in pagination.items]
|
||||
|
||||
return jsonify({
|
||||
"items": items,
|
||||
"total": pagination.total,
|
||||
"page": pagination.page,
|
||||
"per_page": pagination.per_page,
|
||||
"pages": pagination.pages,
|
||||
}), 200
|
||||
|
|
@ -83,7 +83,9 @@ def list_properties():
|
|||
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()]
|
||||
neighborhood_ids_list = [
|
||||
int(x) for x in neighborhood_ids_raw.split(",") if x.strip()
|
||||
]
|
||||
except ValueError:
|
||||
neighborhood_ids_list = []
|
||||
if neighborhood_ids_list:
|
||||
|
|
@ -171,16 +173,14 @@ def list_properties():
|
|||
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),
|
||||
)
|
||||
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),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -203,10 +203,7 @@ def list_properties():
|
|||
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()
|
||||
query.order_by(sort_order).offset((page - 1) * per_page).limit(per_page).all()
|
||||
)
|
||||
|
||||
result = PaginatedPropertiesOut(
|
||||
|
|
@ -248,6 +245,46 @@ def contact_property(slug: str):
|
|||
email=lead_in.email,
|
||||
phone=lead_in.phone,
|
||||
message=lead_in.message,
|
||||
source=lead_in.source or "imovel",
|
||||
source_detail=lead_in.source_detail or prop.title,
|
||||
)
|
||||
db.session.add(lead)
|
||||
db.session.commit()
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
ContactLeadCreatedOut(
|
||||
id=lead.id, message="Mensagem enviada com sucesso!"
|
||||
).model_dump()
|
||||
),
|
||||
201,
|
||||
)
|
||||
|
||||
|
||||
@properties_bp.post("/contact")
|
||||
def contact_general():
|
||||
"""Contato geral (página /contato e /cadastro-residencia)."""
|
||||
from app.models.lead import ContactLead
|
||||
from app.schemas.lead import ContactLeadIn, ContactLeadCreatedOut
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
lead_in = ContactLeadIn.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
import json as _json
|
||||
return jsonify({"error": "Dados inválidos", "details": _json.loads(exc.json())}), 422
|
||||
|
||||
valid_sources = {"contato", "imovel", "cadastro_residencia"}
|
||||
source = lead_in.source if lead_in.source in valid_sources else "contato"
|
||||
|
||||
lead = ContactLead(
|
||||
property_id=None,
|
||||
name=lead_in.name,
|
||||
email=lead_in.email,
|
||||
phone=lead_in.phone,
|
||||
message=lead_in.message,
|
||||
source=source,
|
||||
source_detail=lead_in.source_detail,
|
||||
)
|
||||
db.session.add(lead)
|
||||
db.session.commit()
|
||||
|
|
|
|||
|
|
@ -13,14 +13,51 @@ class PropertyBrief(BaseModel):
|
|||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PropertyCard(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
slug: str
|
||||
price: Optional[Decimal] = None
|
||||
city: Optional[str] = None
|
||||
neighborhood: Optional[str] = None
|
||||
cover_photo_url: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@classmethod
|
||||
def from_property(cls, prop) -> "PropertyCard":
|
||||
cover = prop.photos[0].url if prop.photos else None
|
||||
city = prop.city.name if prop.city else None
|
||||
neighborhood = prop.neighborhood.name if prop.neighborhood else None
|
||||
return cls(
|
||||
id=str(prop.id),
|
||||
title=prop.title,
|
||||
slug=prop.slug,
|
||||
price=prop.price,
|
||||
city=city,
|
||||
neighborhood=neighborhood,
|
||||
cover_photo_url=cover,
|
||||
)
|
||||
|
||||
|
||||
class SavedPropertyOut(BaseModel):
|
||||
id: str
|
||||
property_id: Optional[str]
|
||||
property: Optional[PropertyBrief]
|
||||
property: Optional[PropertyCard]
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@classmethod
|
||||
def from_saved(cls, saved) -> "SavedPropertyOut":
|
||||
prop_card = PropertyCard.from_property(saved.property) if saved.property else None
|
||||
return cls(
|
||||
id=str(saved.id),
|
||||
property_id=str(saved.property_id) if saved.property_id else None,
|
||||
property=prop_card,
|
||||
created_at=saved.created_at,
|
||||
)
|
||||
|
||||
|
||||
class FavoriteIn(BaseModel):
|
||||
property_id: str
|
||||
|
|
@ -93,3 +130,34 @@ class BoletoCreateIn(BaseModel):
|
|||
if v <= 0:
|
||||
raise ValueError("Valor deve ser positivo")
|
||||
return v
|
||||
|
||||
|
||||
class UpdateProfileIn(BaseModel):
|
||||
name: str
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("Nome não pode ser vazio")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class UpdateProfileOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UpdatePasswordIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def min_length(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("A nova senha deve ter pelo menos 8 caracteres")
|
||||
return v
|
||||
|
|
|
|||
43
backend/app/schemas/contact_config.py
Normal file
43
backend/app/schemas/contact_config.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
|
||||
class ContactConfigOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
address_street: str | None = None
|
||||
address_neighborhood_city: str | None = None
|
||||
address_zip: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
business_hours: str | None = None
|
||||
|
||||
|
||||
class ContactConfigIn(BaseModel):
|
||||
address_street: str | None = None
|
||||
address_neighborhood_city: str | None = None
|
||||
address_zip: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
business_hours: str | None = None
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def validate_email(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
import re
|
||||
|
||||
v = v.strip().lower()
|
||||
if v and not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", v):
|
||||
raise ValueError("E-mail inválido.")
|
||||
return v or None
|
||||
|
||||
@field_validator("phone")
|
||||
@classmethod
|
||||
def validate_phone(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
v = v.strip()
|
||||
return v or None
|
||||
|
|
@ -12,6 +12,8 @@ class HomepageConfigOut(BaseModel):
|
|||
hero_cta_url: str
|
||||
featured_properties_limit: int
|
||||
hero_image_url: str | None = None
|
||||
hero_image_light_url: str | None = None
|
||||
hero_image_dark_url: str | None = None
|
||||
|
||||
|
||||
class HomepageConfigIn(BaseModel):
|
||||
|
|
@ -21,6 +23,8 @@ class HomepageConfigIn(BaseModel):
|
|||
hero_cta_url: str = "/imoveis"
|
||||
featured_properties_limit: int = 6
|
||||
hero_image_url: str | None = None
|
||||
hero_image_light_url: str | None = None
|
||||
hero_image_dark_url: str | None = None
|
||||
|
||||
@field_validator("hero_headline")
|
||||
@classmethod
|
||||
|
|
@ -35,3 +39,17 @@ class HomepageConfigIn(BaseModel):
|
|||
if not (1 <= v <= 12):
|
||||
raise ValueError("featured_properties_limit deve estar entre 1 e 12")
|
||||
return v
|
||||
|
||||
|
||||
class HomepageHeroImagesIn(BaseModel):
|
||||
hero_image_url: str | None = None
|
||||
hero_image_light_url: str | None = None
|
||||
hero_image_dark_url: str | None = None
|
||||
|
||||
@field_validator("hero_image_url", "hero_image_light_url", "hero_image_dark_url")
|
||||
@classmethod
|
||||
def normalize_empty_to_none(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
trimmed = v.strip()
|
||||
return trimmed or None
|
||||
|
|
|
|||
59
backend/app/schemas/job_application.py
Normal file
59
backend/app/schemas/job_application.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
|
||||
|
||||
ROLE_INTEREST_OPTIONS = [
|
||||
"Corretor(a)",
|
||||
"Assistente Administrativo",
|
||||
"Estagiário(a)",
|
||||
"Outro",
|
||||
]
|
||||
|
||||
|
||||
class JobApplicationIn(BaseModel):
|
||||
name: str
|
||||
email: EmailStr
|
||||
phone: Optional[str] = None
|
||||
role_interest: str
|
||||
message: str
|
||||
file_name: Optional[str] = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_not_empty(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("Nome não pode ser vazio")
|
||||
return v
|
||||
|
||||
@field_validator("role_interest")
|
||||
@classmethod
|
||||
def valid_role(cls, v: str) -> str:
|
||||
if v not in ROLE_INTEREST_OPTIONS:
|
||||
raise ValueError(f"Cargo inválido. Opções: {ROLE_INTEREST_OPTIONS}")
|
||||
return v
|
||||
|
||||
@field_validator("message")
|
||||
@classmethod
|
||||
def message_not_empty(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("Mensagem não pode ser vazia")
|
||||
if len(v) > 5000:
|
||||
raise ValueError("Mensagem não pode ultrapassar 5000 caracteres")
|
||||
return v
|
||||
|
||||
|
||||
class JobApplicationOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
email: str
|
||||
phone: Optional[str]
|
||||
role_interest: str
|
||||
message: str
|
||||
file_name: Optional[str]
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
|
@ -13,6 +13,8 @@ class ContactLeadIn(BaseModel):
|
|||
email: str
|
||||
phone: str | None = None
|
||||
message: str
|
||||
source: str | None = None
|
||||
source_detail: str | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ from typing import Literal
|
|||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.schemas.catalog import AmenityOut, ImobiliariaOut, PropertyTypeOut, CityOut, NeighborhoodOut
|
||||
from app.schemas.catalog import (
|
||||
AmenityOut,
|
||||
ImobiliariaOut,
|
||||
PropertyTypeOut,
|
||||
CityOut,
|
||||
NeighborhoodOut,
|
||||
)
|
||||
|
||||
|
||||
class PropertyPhotoOut(BaseModel):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
"""add source and source_detail to contact_leads
|
||||
|
||||
Revision ID: g1h2i3j4k5l6
|
||||
Revises: f2a3b4c5d6e7
|
||||
Create Date: 2026-04-21 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "g1h2i3j4k5l6"
|
||||
down_revision = "f2a3b4c5d6e7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"contact_leads",
|
||||
sa.Column("source", sa.String(100), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"contact_leads",
|
||||
sa.Column("source_detail", sa.String(255), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("contact_leads", "source_detail")
|
||||
op.drop_column("contact_leads", "source")
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
"""create contact_config table
|
||||
|
||||
Revision ID: h1i2j3k4l5m6
|
||||
Revises: g1h2i3j4k5l6
|
||||
Create Date: 2026-04-21 00:01:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "h1i2j3k4l5m6"
|
||||
down_revision = "g1h2i3j4k5l6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"contact_config",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("address_street", sa.String(200), nullable=True),
|
||||
sa.Column("address_neighborhood_city", sa.String(200), nullable=True),
|
||||
sa.Column("address_zip", sa.String(20), nullable=True),
|
||||
sa.Column("phone", sa.String(30), nullable=True),
|
||||
sa.Column("email", sa.String(254), nullable=True),
|
||||
sa.Column("business_hours", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
# Seed inicial com os valores atualmente hardcoded na página de contato
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO contact_config (
|
||||
id, address_street, address_neighborhood_city, address_zip,
|
||||
phone, email, business_hours
|
||||
) VALUES (
|
||||
1,
|
||||
'Rua das Imobiliárias, 123',
|
||||
'Centro — São Paulo, SP',
|
||||
'CEP 01000-000',
|
||||
'(11) 99999-0000',
|
||||
'contato@imobiliariahub.com.br',
|
||||
'Segunda a sexta: 9h às 18h\nSábados: 9h às 13h\nDomingos e feriados: fechado'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("contact_config")
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
"""add job_applications table
|
||||
|
||||
Revision ID: i1j2k3l4m5n6
|
||||
Revises: h1i2j3k4l5m6
|
||||
Create Date: 2026-04-21 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "i1j2k3l4m5n6"
|
||||
down_revision = "h1i2j3k4l5m6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"job_applications",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("name", sa.String(150), nullable=False),
|
||||
sa.Column("email", sa.String(254), nullable=False),
|
||||
sa.Column("phone", sa.String(30), nullable=True),
|
||||
sa.Column("role_interest", sa.String(100), nullable=False),
|
||||
sa.Column("message", sa.Text(), nullable=False),
|
||||
sa.Column("file_name", sa.String(255), nullable=True),
|
||||
sa.Column("status", sa.String(50), nullable=False, server_default="pending"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_job_applications_created_at", "job_applications", ["created_at"], unique=False)
|
||||
op.create_index("ix_job_applications_status", "job_applications", ["status"], unique=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_job_applications_status", table_name="job_applications")
|
||||
op.drop_index("ix_job_applications_created_at", table_name="job_applications")
|
||||
op.drop_table("job_applications")
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"""add homepage hero light/dark image urls
|
||||
|
||||
Revision ID: j2k3l4m5n6o7
|
||||
Revises: i1j2k3l4m5n6
|
||||
Create Date: 2026-04-22 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "j2k3l4m5n6o7"
|
||||
down_revision = "i1j2k3l4m5n6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"homepage_config",
|
||||
sa.Column("hero_image_light_url", sa.String(length=512), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"homepage_config",
|
||||
sa.Column("hero_image_dark_url", sa.String(length=512), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("homepage_config", "hero_image_dark_url")
|
||||
op.drop_column("homepage_config", "hero_image_light_url")
|
||||
|
|
@ -694,6 +694,9 @@ def seed() -> None:
|
|||
hero_cta_label="Ver Imóveis",
|
||||
hero_cta_url="/imoveis",
|
||||
featured_properties_limit=6,
|
||||
hero_image_url="https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?auto=format&fit=crop&w=1920&q=80",
|
||||
hero_image_light_url="https://images.unsplash.com/photo-1600585154526-990dced4db0d?auto=format&fit=crop&w=1920&q=80",
|
||||
hero_image_dark_url="https://images.unsplash.com/photo-1600607687644-aac4c3eac7f4?auto=format&fit=crop&w=1920&q=80",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -775,6 +778,50 @@ def seed() -> None:
|
|||
db.session.commit()
|
||||
print(f"Admin: {ADMIN_EMAIL}")
|
||||
|
||||
# ── Generic admin (credenciais simples para demo) ────────────────────
|
||||
GENERIC_ADMIN_EMAIL = "admin@demo.com"
|
||||
GENERIC_ADMIN_PASSWORD = "admin1234"
|
||||
gadmin = ClientUser.query.filter_by(email=GENERIC_ADMIN_EMAIL).first()
|
||||
if not gadmin:
|
||||
gadmin = ClientUser(
|
||||
name="Admin Demo",
|
||||
email=GENERIC_ADMIN_EMAIL,
|
||||
password_hash=bcrypt.hashpw(
|
||||
GENERIC_ADMIN_PASSWORD.encode(), bcrypt.gensalt()
|
||||
).decode(),
|
||||
role="admin",
|
||||
)
|
||||
db.session.add(gadmin)
|
||||
else:
|
||||
gadmin.password_hash = bcrypt.hashpw(
|
||||
GENERIC_ADMIN_PASSWORD.encode(), bcrypt.gensalt()
|
||||
).decode()
|
||||
gadmin.role = "admin"
|
||||
db.session.commit()
|
||||
print(f"Generic admin: {GENERIC_ADMIN_EMAIL}")
|
||||
|
||||
# ── Demo user (sem acesso admin) ─────────────────────────────────────
|
||||
DEMO_EMAIL = "usuario@demo.com"
|
||||
DEMO_PASSWORD = "demo1234"
|
||||
demo = ClientUser.query.filter_by(email=DEMO_EMAIL).first()
|
||||
if not demo:
|
||||
demo = ClientUser(
|
||||
name="Usuário Demo",
|
||||
email=DEMO_EMAIL,
|
||||
password_hash=bcrypt.hashpw(
|
||||
DEMO_PASSWORD.encode(), bcrypt.gensalt()
|
||||
).decode(),
|
||||
role="user",
|
||||
)
|
||||
db.session.add(demo)
|
||||
else:
|
||||
demo.password_hash = bcrypt.hashpw(
|
||||
DEMO_PASSWORD.encode(), bcrypt.gensalt()
|
||||
).decode()
|
||||
demo.role = "user"
|
||||
db.session.commit()
|
||||
print(f"Demo user: {DEMO_EMAIL}")
|
||||
|
||||
total_amenities = sum(len(v) for v in AMENITIES.values())
|
||||
total_types = sum(1 + len(c["subtypes"]) for c in PROPERTY_TYPES)
|
||||
total_cities = len(LOCATIONS)
|
||||
|
|
|
|||
420
backend/tests/test_contact_flow.py
Normal file
420
backend/tests/test_contact_flow.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
"""
|
||||
Testes de integração — fluxo completo de contato.
|
||||
|
||||
Cobre os três caminhos de submissão de lead:
|
||||
1. POST /api/v1/contact (contato geral)
|
||||
2. POST /api/v1/properties/<slug>/contact (contato de imóvel)
|
||||
3. POST /api/v1/contact com source=cadastro_residencia
|
||||
|
||||
Além da visualização admin via:
|
||||
- GET /api/v1/admin/leads
|
||||
- GET /api/v1/admin/leads?source=<origem>
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import jwt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
import pytest
|
||||
|
||||
from app.extensions import db as _db
|
||||
from app.models.lead import ContactLead
|
||||
from app.models.property import Property
|
||||
from app.models.user import ClientUser
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_TEST_JWT_SECRET = "test-secret-key"
|
||||
|
||||
_VALID_CONTACT = {
|
||||
"name": "João da Silva",
|
||||
"email": "joao@example.com",
|
||||
"phone": "(11) 91234-5678",
|
||||
"message": "Tenho interesse em anunciar meu imóvel.",
|
||||
}
|
||||
|
||||
|
||||
def _make_admin_token(user_id: str) -> str:
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
"iat": datetime.now(timezone.utc),
|
||||
}
|
||||
return jwt.encode(payload, _TEST_JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
def _admin_headers(user_id: str) -> dict:
|
||||
return {"Authorization": f"Bearer {_make_admin_token(user_id)}"}
|
||||
|
||||
|
||||
def _make_property(slug: str) -> Property:
|
||||
return Property(
|
||||
id=uuid.uuid4(),
|
||||
title=f"Apartamento {slug}",
|
||||
slug=slug,
|
||||
address="Rua Teste, 10",
|
||||
price="350000.00",
|
||||
type="venda",
|
||||
bedrooms=2,
|
||||
bathrooms=1,
|
||||
area_m2=70,
|
||||
is_featured=False,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
def _make_admin_user(db) -> ClientUser:
|
||||
pwd_hash = bcrypt.hashpw(b"admin123", bcrypt.gensalt()).decode()
|
||||
admin = ClientUser(
|
||||
name="Admin Teste",
|
||||
email="admin@test.com",
|
||||
password_hash=pwd_hash,
|
||||
role="admin",
|
||||
)
|
||||
db.session.add(admin)
|
||||
db.session.flush()
|
||||
return admin
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Fixtures
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_jwt_secret(app):
|
||||
"""Garante que os testes usem o mesmo secret para gerar e validar tokens."""
|
||||
app.config["JWT_SECRET_KEY"] = _TEST_JWT_SECRET
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 1. POST /api/v1/contact — contato geral
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestContactGeneral:
|
||||
def test_returns_201_with_id(self, client):
|
||||
res = client.post("/api/v1/contact", json=_VALID_CONTACT)
|
||||
assert res.status_code == 201
|
||||
body = res.get_json()
|
||||
assert "id" in body
|
||||
assert body["message"] == "Mensagem enviada com sucesso!"
|
||||
|
||||
def test_lead_persisted_in_db(self, client, db):
|
||||
client.post("/api/v1/contact", json=_VALID_CONTACT)
|
||||
lead = db.session.query(ContactLead).filter_by(email="joao@example.com").first()
|
||||
assert lead is not None
|
||||
assert lead.name == "João da Silva"
|
||||
assert lead.source == "contato"
|
||||
|
||||
def test_source_defaults_to_contato_when_absent(self, client, db):
|
||||
payload = {**_VALID_CONTACT, "email": "sem_source@example.com"}
|
||||
client.post("/api/v1/contact", json=payload)
|
||||
lead = db.session.query(ContactLead).filter_by(email="sem_source@example.com").first()
|
||||
assert lead.source == "contato"
|
||||
|
||||
def test_explicit_source_contato_is_preserved(self, client, db):
|
||||
payload = {**_VALID_CONTACT, "email": "src_contato@example.com", "source": "contato"}
|
||||
client.post("/api/v1/contact", json=payload)
|
||||
lead = db.session.query(ContactLead).filter_by(email="src_contato@example.com").first()
|
||||
assert lead.source == "contato"
|
||||
|
||||
def test_unknown_source_falls_back_to_contato(self, client, db):
|
||||
payload = {**_VALID_CONTACT, "email": "bad_source@example.com", "source": "spam"}
|
||||
client.post("/api/v1/contact", json=payload)
|
||||
lead = db.session.query(ContactLead).filter_by(email="bad_source@example.com").first()
|
||||
assert lead.source == "contato"
|
||||
|
||||
def test_missing_name_returns_422(self, client):
|
||||
payload = {k: v for k, v in _VALID_CONTACT.items() if k != "name"}
|
||||
res = client.post("/api/v1/contact", json=payload)
|
||||
assert res.status_code == 422
|
||||
|
||||
def test_invalid_email_returns_422(self, client):
|
||||
payload = {**_VALID_CONTACT, "email": "not-an-email"}
|
||||
res = client.post("/api/v1/contact", json=payload)
|
||||
assert res.status_code == 422
|
||||
|
||||
def test_missing_message_returns_422(self, client):
|
||||
payload = {k: v for k, v in _VALID_CONTACT.items() if k != "message"}
|
||||
res = client.post("/api/v1/contact", json=payload)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 2. POST /api/v1/contact com source=cadastro_residencia
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestContactCadastroResidencia:
|
||||
def test_source_cadastro_residencia_is_saved(self, client, db):
|
||||
payload = {
|
||||
**_VALID_CONTACT,
|
||||
"email": "captacao@example.com",
|
||||
"source": "cadastro_residencia",
|
||||
"source_detail": "Apartamento",
|
||||
}
|
||||
res = client.post("/api/v1/contact", json=payload)
|
||||
assert res.status_code == 201
|
||||
|
||||
lead = db.session.query(ContactLead).filter_by(email="captacao@example.com").first()
|
||||
assert lead is not None
|
||||
assert lead.source == "cadastro_residencia"
|
||||
assert lead.source_detail == "Apartamento"
|
||||
assert lead.property_id is None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 3. POST /api/v1/properties/<slug>/contact — contato de imóvel
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestContactProperty:
|
||||
def test_returns_201_and_persists_lead(self, client, db):
|
||||
prop = _make_property("apto-centro-test")
|
||||
db.session.add(prop)
|
||||
db.session.commit()
|
||||
|
||||
res = client.post("/api/v1/properties/apto-centro-test/contact", json=_VALID_CONTACT)
|
||||
assert res.status_code == 201
|
||||
|
||||
lead = db.session.query(ContactLead).filter_by(email="joao@example.com").first()
|
||||
assert lead is not None
|
||||
assert lead.property_id == prop.id
|
||||
assert lead.source == "imovel"
|
||||
assert lead.source_detail == prop.title
|
||||
|
||||
def test_returns_404_for_unknown_slug(self, client):
|
||||
res = client.post("/api/v1/properties/slug-inexistente/contact", json=_VALID_CONTACT)
|
||||
assert res.status_code == 404
|
||||
|
||||
def test_inactive_property_returns_404(self, client, db):
|
||||
prop = _make_property("apto-inativo")
|
||||
prop.is_active = False
|
||||
db.session.add(prop)
|
||||
db.session.commit()
|
||||
|
||||
res = client.post("/api/v1/properties/apto-inativo/contact", json=_VALID_CONTACT)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 4. GET /api/v1/admin/leads — visualização admin
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestAdminLeads:
|
||||
def test_requires_authentication(self, client):
|
||||
res = client.get("/api/v1/admin/leads")
|
||||
assert res.status_code == 401
|
||||
|
||||
def test_non_admin_user_gets_403(self, client, db):
|
||||
pwd_hash = bcrypt.hashpw(b"user123", bcrypt.gensalt()).decode()
|
||||
user = ClientUser(
|
||||
name="Usuário Comum",
|
||||
email="comum@test.com",
|
||||
password_hash=pwd_hash,
|
||||
role="client",
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
|
||||
res = client.get(
|
||||
"/api/v1/admin/leads",
|
||||
headers=_admin_headers(user.id),
|
||||
)
|
||||
assert res.status_code == 403
|
||||
|
||||
def test_admin_sees_all_leads(self, client, db):
|
||||
admin = _make_admin_user(db)
|
||||
|
||||
# Cria 3 leads via API para garantir persistência realista
|
||||
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "a1@ex.com", "source": "contato"})
|
||||
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "a2@ex.com", "source": "cadastro_residencia"})
|
||||
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "a3@ex.com", "source": "cadastro_residencia"})
|
||||
|
||||
res = client.get("/api/v1/admin/leads", headers=_admin_headers(admin.id))
|
||||
assert res.status_code == 200
|
||||
|
||||
body = res.get_json()
|
||||
assert "items" in body
|
||||
assert "total" in body
|
||||
assert body["total"] >= 3
|
||||
|
||||
def test_response_has_required_fields(self, client, db):
|
||||
admin = _make_admin_user(db)
|
||||
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "fields@ex.com"})
|
||||
|
||||
res = client.get("/api/v1/admin/leads", headers=_admin_headers(admin.id))
|
||||
item = res.get_json()["items"][0]
|
||||
|
||||
for field in ("id", "name", "email", "phone", "message", "source", "source_detail", "created_at"):
|
||||
assert field in item, f"Campo ausente na resposta: {field}"
|
||||
|
||||
def test_filter_by_source_contato(self, client, db):
|
||||
admin = _make_admin_user(db)
|
||||
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "f_contato@ex.com", "source": "contato"})
|
||||
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "f_captacao@ex.com", "source": "cadastro_residencia"})
|
||||
|
||||
res = client.get("/api/v1/admin/leads?source=contato", headers=_admin_headers(admin.id))
|
||||
body = res.get_json()
|
||||
|
||||
sources = {item["source"] for item in body["items"]}
|
||||
assert sources == {"contato"}
|
||||
|
||||
def test_filter_by_source_cadastro_residencia(self, client, db):
|
||||
admin = _make_admin_user(db)
|
||||
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "cr1@ex.com", "source": "cadastro_residencia"})
|
||||
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "cr2@ex.com", "source": "contato"})
|
||||
|
||||
res = client.get("/api/v1/admin/leads?source=cadastro_residencia", headers=_admin_headers(admin.id))
|
||||
body = res.get_json()
|
||||
|
||||
assert all(item["source"] == "cadastro_residencia" for item in body["items"])
|
||||
|
||||
def test_filter_by_source_imovel(self, client, db):
|
||||
admin = _make_admin_user(db)
|
||||
prop = _make_property("apto-filtro-imovel")
|
||||
db.session.add(prop)
|
||||
db.session.commit()
|
||||
|
||||
client.post("/api/v1/properties/apto-filtro-imovel/contact", json={**_VALID_CONTACT, "email": "imovel_f@ex.com"})
|
||||
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "contato_f@ex.com", "source": "contato"})
|
||||
|
||||
res = client.get("/api/v1/admin/leads?source=imovel", headers=_admin_headers(admin.id))
|
||||
body = res.get_json()
|
||||
|
||||
assert all(item["source"] == "imovel" for item in body["items"])
|
||||
|
||||
def test_pagination_defaults(self, client, db):
|
||||
admin = _make_admin_user(db)
|
||||
res = client.get("/api/v1/admin/leads", headers=_admin_headers(admin.id))
|
||||
body = res.get_json()
|
||||
|
||||
assert body["page"] == 1
|
||||
assert body["per_page"] == 20
|
||||
assert "pages" in body
|
||||
|
||||
def test_pagination_page_2(self, client, db):
|
||||
admin = _make_admin_user(db)
|
||||
|
||||
# Cria 25 leads
|
||||
for i in range(25):
|
||||
client.post(
|
||||
"/api/v1/contact",
|
||||
json={**_VALID_CONTACT, "email": f"page_test_{i}@ex.com"},
|
||||
)
|
||||
|
||||
res_p1 = client.get("/api/v1/admin/leads?per_page=10&page=1", headers=_admin_headers(admin.id))
|
||||
res_p2 = client.get("/api/v1/admin/leads?per_page=10&page=2", headers=_admin_headers(admin.id))
|
||||
|
||||
assert len(res_p1.get_json()["items"]) == 10
|
||||
assert len(res_p2.get_json()["items"]) == 10
|
||||
|
||||
ids_p1 = {item["id"] for item in res_p1.get_json()["items"]}
|
||||
ids_p2 = {item["id"] for item in res_p2.get_json()["items"]}
|
||||
assert ids_p1.isdisjoint(ids_p2), "Páginas não devem ter leads em comum"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 5. Fluxo end-to-end completo
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestEndToEndContactFlow:
|
||||
"""Valida o caminho completo: submissão pública → visualização admin."""
|
||||
|
||||
def test_property_contact_appears_in_admin_with_correct_metadata(self, client, db):
|
||||
admin = _make_admin_user(db)
|
||||
prop = _make_property("apto-e2e-flow")
|
||||
db.session.add(prop)
|
||||
db.session.commit()
|
||||
|
||||
# 1. Cliente envia contato pelo imóvel
|
||||
submit_res = client.post(
|
||||
"/api/v1/properties/apto-e2e-flow/contact",
|
||||
json={
|
||||
"name": "Maria Souza",
|
||||
"email": "maria@example.com",
|
||||
"phone": "(21) 98765-4321",
|
||||
"message": "Quero agendar uma visita.",
|
||||
},
|
||||
)
|
||||
assert submit_res.status_code == 201
|
||||
lead_id = submit_res.get_json()["id"]
|
||||
|
||||
# 2. Admin lista todos os leads e localiza o criado
|
||||
list_res = client.get("/api/v1/admin/leads", headers=_admin_headers(admin.id))
|
||||
assert list_res.status_code == 200
|
||||
|
||||
items = list_res.get_json()["items"]
|
||||
match = next((item for item in items if item["id"] == lead_id), None)
|
||||
assert match is not None, "Lead não encontrado na listagem admin"
|
||||
|
||||
assert match["name"] == "Maria Souza"
|
||||
assert match["email"] == "maria@example.com"
|
||||
assert match["phone"] == "(21) 98765-4321"
|
||||
assert match["message"] == "Quero agendar uma visita."
|
||||
assert match["source"] == "imovel"
|
||||
assert match["source_detail"] == prop.title
|
||||
assert match["property_id"] == str(prop.id)
|
||||
assert match["created_at"] is not None
|
||||
|
||||
def test_general_contact_appears_in_admin_without_property_id(self, client, db):
|
||||
admin = _make_admin_user(db)
|
||||
|
||||
# 1. Cliente envia contato geral
|
||||
submit_res = client.post(
|
||||
"/api/v1/contact",
|
||||
json={
|
||||
"name": "Carlos Lima",
|
||||
"email": "carlos@example.com",
|
||||
"phone": "(31) 97654-3210",
|
||||
"message": "Quero informações sobre venda.",
|
||||
"source": "contato",
|
||||
},
|
||||
)
|
||||
assert submit_res.status_code == 201
|
||||
lead_id = submit_res.get_json()["id"]
|
||||
|
||||
# 2. Admin filtra por source=contato e encontra o lead
|
||||
list_res = client.get(
|
||||
"/api/v1/admin/leads?source=contato",
|
||||
headers=_admin_headers(admin.id),
|
||||
)
|
||||
items = list_res.get_json()["items"]
|
||||
match = next((item for item in items if item["id"] == lead_id), None)
|
||||
assert match is not None
|
||||
|
||||
assert match["property_id"] is None
|
||||
assert match["source"] == "contato"
|
||||
|
||||
def test_cadastro_residencia_appears_in_admin_with_source_detail(self, client, db):
|
||||
admin = _make_admin_user(db)
|
||||
|
||||
# 1. Cliente submete formulário de captação
|
||||
submit_res = client.post(
|
||||
"/api/v1/contact",
|
||||
json={
|
||||
"name": "Ana Paula",
|
||||
"email": "ana@example.com",
|
||||
"phone": "(41) 96543-2109",
|
||||
"message": "Finalidade: Venda\nTipo: Casa\nValor: R$ 800.000",
|
||||
"source": "cadastro_residencia",
|
||||
"source_detail": "Casa",
|
||||
},
|
||||
)
|
||||
assert submit_res.status_code == 201
|
||||
lead_id = submit_res.get_json()["id"]
|
||||
|
||||
# 2. Admin filtra por source=cadastro_residencia
|
||||
list_res = client.get(
|
||||
"/api/v1/admin/leads?source=cadastro_residencia",
|
||||
headers=_admin_headers(admin.id),
|
||||
)
|
||||
items = list_res.get_json()["items"]
|
||||
match = next((item for item in items if item["id"] == lead_id), None)
|
||||
assert match is not None
|
||||
|
||||
assert match["source"] == "cadastro_residencia"
|
||||
assert match["source_detail"] == "Casa"
|
||||
assert match["property_id"] is None
|
||||
assert "Venda" in match["message"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue