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