feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
Some checks failed
CI/CD → Deploy via SSH / Build & Push Docker Images (push) Successful in 1m0s
CI/CD → Deploy via SSH / Deploy via SSH (push) Successful in 4m35s
CI/CD → Deploy via SSH / Validate HTTPS & Endpoints (push) Failing after 46s

- 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:
MatheusAlves96 2026-04-22 22:35:17 -03:00
parent 6ef5a7a17e
commit cf5603243c
106 changed files with 11927 additions and 1367 deletions

View file

@ -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

View file

@ -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

View 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}>"

View file

@ -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,

View 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}>"

View file

@ -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}>"

View file

@ -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())

View file

@ -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

View 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())

View file

@ -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

View 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

View file

@ -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()

View file

@ -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

View 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

View file

@ -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

View 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

View file

@ -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

View file

@ -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):

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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)

View 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"]