feat: add full project - backend, frontend, docker, specs and configs

This commit is contained in:
MatheusAlves96 2026-04-20 23:59:45 -03:00
parent b77c7d5a01
commit e6cb06255b
24489 changed files with 61341 additions and 36 deletions

81
backend/app/__init__.py Normal file
View file

@ -0,0 +1,81 @@
import os
from flask import Flask
from dotenv import load_dotenv
load_dotenv()
from app.config import config
from app.extensions import db, migrate, cors
def create_app(config_name: str | None = None) -> Flask:
if config_name is None:
config_name = os.environ.get("FLASK_ENV", "default")
if config_name not in config:
config_name = "default"
app = Flask(__name__)
app.config.from_object(config[config_name])
# Initialize extensions
db.init_app(app)
migrate.init_app(app, db)
cors.init_app(
app,
resources={r"/api/*": {"origins": app.config["CORS_ORIGINS"]}},
)
# Import models so Flask-Migrate can detect them
from app.models import property as _property_models # noqa: F401
from app.models import homepage as _homepage_models # noqa: F401
from app.models import catalog as _catalog_models # noqa: F401
from app.models import location as _location_models # noqa: F401
from app.models import lead as _lead_models # noqa: F401
from app.models import user as _user_models # noqa: F401
from app.models import saved_property as _saved_property_models # noqa: F401
from app.models import visit_request as _visit_request_models # noqa: F401
from app.models import boleto as _boleto_models # noqa: F401
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
# JWT secret key — raises KeyError if not set (fail-fast on misconfiguration)
app.config["JWT_SECRET_KEY"] = os.environ["JWT_SECRET_KEY"]
# Register blueprints
from app.routes.homepage import homepage_bp
from app.routes.properties import properties_bp
from app.routes.catalog import catalog_bp
from app.routes.locations import locations_bp
from app.routes.auth import auth_bp
from app.routes.client_area import client_bp
from app.routes.admin import admin_bp
from app.routes.analytics import analytics_bp, _should_track, record_page_view
from app.routes.config import config_bp
from app.routes.agents import agents_public_bp, agents_admin_bp
app.register_blueprint(homepage_bp)
app.register_blueprint(properties_bp)
app.register_blueprint(catalog_bp)
app.register_blueprint(locations_bp)
app.register_blueprint(auth_bp, url_prefix="/api/v1/auth")
app.register_blueprint(client_bp, url_prefix="/api/v1/me")
app.register_blueprint(admin_bp, url_prefix="/api/v1/admin")
app.register_blueprint(analytics_bp, url_prefix="/api/v1/admin/analytics")
app.register_blueprint(config_bp)
app.register_blueprint(agents_public_bp)
app.register_blueprint(agents_admin_bp)
@app.before_request
def track_page_view():
from flask import request as req
if _should_track(req.path, req.method):
ip = (
req.headers.get("X-Forwarded-For", req.remote_addr or "")
.split(",")[0]
.strip()
)
record_page_view(req.path, ip, req.headers.get("User-Agent", ""))
return app

42
backend/app/config.py Normal file
View file

@ -0,0 +1,42 @@
import os
class BaseConfig:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key-change-in-production")
SQLALCHEMY_TRACK_MODIFICATIONS = False
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "http://localhost:5173").split(",")
UPLOAD_FOLDER = os.environ.get(
"UPLOAD_FOLDER",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "uploads"),
)
class DevelopmentConfig(BaseConfig):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL",
"postgresql://imob:imob_dev@localhost:5432/saas_imobiliaria",
)
class ProductionConfig(BaseConfig):
DEBUG = False
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
class TestingConfig(BaseConfig):
TESTING = True
SQLALCHEMY_DATABASE_URI = os.environ.get(
"TEST_DATABASE_URL",
"sqlite:///:memory:",
)
# Disable CSRF for testing
WTF_CSRF_ENABLED = False
config: dict[str, type[BaseConfig]] = {
"development": DevelopmentConfig,
"production": ProductionConfig,
"testing": TestingConfig,
"default": DevelopmentConfig,
}

View file

@ -0,0 +1,7 @@
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_cors import CORS
db = SQLAlchemy()
migrate = Migrate()
cors = CORS()

View file

@ -0,0 +1,5 @@
# Importação explícita dos modelos para registro no metadata
from .saved_property import SavedProperty
from .visit_request import VisitRequest
from .boleto import Boleto
from .page_view import PageView

View file

@ -0,0 +1,25 @@
from app.extensions import db
class Agent(db.Model):
__tablename__ = "agents"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(200), nullable=False)
photo_url = db.Column(db.String(512), nullable=True)
creci = db.Column(db.String(50), nullable=False)
email = db.Column(db.String(200), nullable=False)
phone = db.Column(db.String(30), nullable=False)
bio = db.Column(db.Text, nullable=True)
is_active = db.Column(db.Boolean, nullable=False, default=True)
display_order = db.Column(db.Integer, nullable=False, default=0)
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
updated_at = db.Column(
db.DateTime,
nullable=False,
server_default=db.func.now(),
onupdate=db.func.now(),
)
def __repr__(self) -> str:
return f"<Agent {self.name!r}>"

View file

@ -0,0 +1,30 @@
import uuid
from datetime import datetime
from app.extensions import db
class Boleto(db.Model):
__tablename__ = "boletos"
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = db.Column(
db.String(36),
db.ForeignKey("client_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
property_id = db.Column(
db.UUID(as_uuid=True),
db.ForeignKey("properties.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
description = db.Column(db.String(200), nullable=False)
amount = db.Column(db.Numeric(12, 2), nullable=False)
due_date = db.Column(db.Date, nullable=False)
status = db.Column(db.String(20), nullable=False, default="pending")
url = db.Column(db.String(500), nullable=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
user = db.relationship("ClientUser", backref=db.backref("boletos", lazy="select"))
property = db.relationship("Property", foreign_keys=[property_id], lazy="joined")

View file

@ -0,0 +1,67 @@
import uuid
from app.extensions import db
# Association table N:N between Property and Amenity — no model class needed
property_amenity = db.Table(
"property_amenity",
db.Column(
"property_id",
db.UUID(as_uuid=True),
db.ForeignKey("properties.id", ondelete="CASCADE"),
primary_key=True,
),
db.Column(
"amenity_id",
db.Integer,
db.ForeignKey("amenities.id", ondelete="CASCADE"),
primary_key=True,
),
)
class PropertyType(db.Model):
"""Hierarchical property type: Residencial > Apartamento"""
__tablename__ = "property_types"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(100), nullable=False)
slug = db.Column(db.String(120), unique=True, nullable=False, index=True)
parent_id = db.Column(
db.Integer,
db.ForeignKey("property_types.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# Self-referential relationship
subtypes = db.relationship(
"PropertyType",
backref=db.backref("parent", remote_side=[id]),
lazy="select",
)
def __repr__(self) -> str:
return f"<PropertyType {self.slug!r}>"
class Amenity(db.Model):
"""Tagged feature/amenity for a property (characteristic, leisure, condo, security)"""
__tablename__ = "amenities"
GROUPS = ("caracteristica", "lazer", "condominio", "seguranca")
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(100), nullable=False)
slug = db.Column(db.String(120), unique=True, nullable=False, index=True)
group = db.Column(
db.Enum(
"caracteristica", "lazer", "condominio", "seguranca", name="amenity_group"
),
nullable=False,
)
def __repr__(self) -> str:
return f"<Amenity {self.slug!r} group={self.group!r}>"

View file

@ -0,0 +1,22 @@
from app.extensions import db
class HomepageConfig(db.Model):
__tablename__ = "homepage_config"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
hero_headline = db.Column(db.String(120), nullable=False)
hero_subheadline = db.Column(db.String(240), nullable=True)
hero_cta_label = db.Column(db.String(40), nullable=False, default="Ver Imóveis")
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)
updated_at = db.Column(
db.DateTime,
nullable=False,
server_default=db.func.now(),
onupdate=db.func.now(),
)
def __repr__(self) -> str:
return f"<HomepageConfig id={self.id!r} headline={self.hero_headline!r}>"

View file

@ -0,0 +1,18 @@
from app.extensions import db
class Imobiliaria(db.Model):
__tablename__ = "imobiliarias"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(200), nullable=False)
logo_url = db.Column(db.String(512), nullable=True)
website = db.Column(db.String(512), nullable=True)
is_active = db.Column(db.Boolean, nullable=False, default=True)
display_order = db.Column(db.Integer, nullable=False, default=0)
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
properties = db.relationship("Property", backref="imobiliaria", lazy="dynamic")
def __repr__(self) -> str:
return f"<Imobiliaria {self.name!r}>"

View file

@ -0,0 +1,25 @@
from app.extensions import db
class ContactLead(db.Model):
__tablename__ = "contact_leads"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
property_id = db.Column(
db.UUID(as_uuid=True),
db.ForeignKey("properties.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
name = db.Column(db.String(150), nullable=False)
email = db.Column(db.String(254), nullable=False)
phone = db.Column(db.String(20), nullable=True)
message = db.Column(db.Text, nullable=False)
created_at = db.Column(
db.DateTime(timezone=True),
nullable=False,
server_default=db.func.now(),
)
def __repr__(self) -> str:
return f"<ContactLead id={self.id} email={self.email!r}>"

View file

@ -0,0 +1,46 @@
from app.extensions import db
class City(db.Model):
"""City managed via admin panel."""
__tablename__ = "cities"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(100), nullable=False)
slug = db.Column(db.String(120), unique=True, nullable=False, index=True)
state = db.Column(db.String(2), nullable=False) # UF: SP, RJ, MG, ...
neighborhoods = db.relationship(
"Neighborhood",
backref="city",
order_by="Neighborhood.name",
cascade="all, delete-orphan",
lazy="select",
)
def __repr__(self) -> str:
return f"<City {self.name!r}/{self.state!r}>"
class Neighborhood(db.Model):
"""Neighborhood (bairro) managed via admin panel."""
__tablename__ = "neighborhoods"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(100), nullable=False)
slug = db.Column(db.String(120), nullable=False, index=True)
city_id = db.Column(
db.Integer,
db.ForeignKey("cities.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
__table_args__ = (
db.UniqueConstraint("slug", "city_id", name="uq_neighborhood_slug_city"),
)
def __repr__(self) -> str:
return f"<Neighborhood {self.name!r} city_id={self.city_id}>"

View file

@ -0,0 +1,21 @@
from datetime import datetime, timezone
from app.extensions import db
class PageView(db.Model):
__tablename__ = "page_views"
id = db.Column(db.Integer, primary_key=True)
path = db.Column(db.String(512), nullable=False)
property_id = db.Column(db.String(36), nullable=True, index=True)
accessed_at = db.Column(
db.DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
index=True,
)
ip_hash = db.Column(db.String(64), nullable=True)
user_agent = db.Column(db.String(512), nullable=True)
def __repr__(self) -> str:
return f"<PageView {self.path} at {self.accessed_at}>"

View file

@ -0,0 +1,91 @@
import uuid
from app.extensions import db
class Property(db.Model):
__tablename__ = "properties"
id = db.Column(db.UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = db.Column(db.String(200), nullable=False)
slug = db.Column(db.String(220), unique=True, nullable=False, index=True)
address = db.Column(db.String(300), nullable=True)
price = db.Column(db.Numeric(12, 2), nullable=False)
condo_fee = db.Column(db.Numeric(10, 2), nullable=True)
type = db.Column(
db.Enum("venda", "aluguel", name="property_type"),
nullable=False,
)
subtype_id = db.Column(
db.Integer,
db.ForeignKey("property_types.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
bedrooms = db.Column(db.Integer, nullable=False, default=0)
bathrooms = db.Column(db.Integer, nullable=False, default=0)
parking_spots = db.Column(db.Integer, nullable=False, default=0)
parking_spots_covered = db.Column(db.Integer, nullable=False, default=0)
area_m2 = db.Column(db.Integer, nullable=False)
city_id = db.Column(
db.Integer,
db.ForeignKey("cities.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
neighborhood_id = db.Column(
db.Integer,
db.ForeignKey("neighborhoods.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
is_featured = db.Column(db.Boolean, nullable=False, default=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
code = db.Column(db.String(30), unique=True, nullable=True)
description = db.Column(db.Text, nullable=True)
iptu_anual = db.Column(db.Numeric(12, 2), nullable=True)
imobiliaria_id = db.Column(
db.Integer,
db.ForeignKey("imobiliarias.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
photos = db.relationship(
"PropertyPhoto",
backref="property",
order_by="PropertyPhoto.display_order",
cascade="all, delete-orphan",
lazy="select",
)
subtype = db.relationship("PropertyType", foreign_keys=[subtype_id], lazy="joined")
city = db.relationship("City", foreign_keys=[city_id], lazy="joined")
neighborhood = db.relationship(
"Neighborhood", foreign_keys=[neighborhood_id], lazy="joined"
)
amenities = db.relationship(
"Amenity",
secondary="property_amenity",
lazy="select",
)
def __repr__(self) -> str:
return f"<Property {self.slug!r}>"
class PropertyPhoto(db.Model):
__tablename__ = "property_photos"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
property_id = db.Column(
db.UUID(as_uuid=True),
db.ForeignKey("properties.id", ondelete="CASCADE"),
nullable=False,
)
url = db.Column(db.String(500), nullable=False)
alt_text = db.Column(db.String(200), nullable=False, default="")
display_order = db.Column(db.Integer, nullable=False, default=0)
def __repr__(self) -> str:
return f"<PropertyPhoto property_id={self.property_id!r} order={self.display_order}>"

View file

@ -0,0 +1,33 @@
import uuid
from datetime import datetime
from app.extensions import db
class SavedProperty(db.Model):
__tablename__ = "saved_properties"
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = db.Column(
db.String(36),
db.ForeignKey("client_users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
property_id = db.Column(
db.UUID(as_uuid=True),
db.ForeignKey("properties.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
db.UniqueConstraint(
"user_id", "property_id", name="uq_saved_properties_user_property"
),
)
user = db.relationship(
"ClientUser", backref=db.backref("saved_properties", lazy="joined")
)
property = db.relationship("Property", foreign_keys=[property_id], lazy="joined")

View file

@ -0,0 +1,38 @@
import uuid
from datetime import datetime
from app.extensions import db
class ClientUser(db.Model):
__tablename__ = "client_users"
id = db.Column(
db.String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
name = db.Column(db.String(150), nullable=False)
email = db.Column(db.String(254), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(100), nullable=False)
role = db.Column(db.String(20), nullable=False, default="client")
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# Contato
phone = db.Column(db.String(20), nullable=True)
whatsapp = db.Column(db.String(20), nullable=True)
# Dados pessoais
cpf = db.Column(db.String(14), nullable=True)
birth_date = db.Column(db.Date, nullable=True)
# Endereço
address_street = db.Column(db.String(200), nullable=True)
address_number = db.Column(db.String(20), nullable=True)
address_complement = db.Column(db.String(100), nullable=True)
address_neighborhood = db.Column(db.String(100), nullable=True)
address_city = db.Column(db.String(100), nullable=True)
address_state = db.Column(db.String(2), nullable=True)
address_zip = db.Column(db.String(9), nullable=True)
# Observações internas (admin)
notes = db.Column(db.Text, nullable=True)

View file

@ -0,0 +1,30 @@
import uuid
from datetime import datetime
from app.extensions import db
class VisitRequest(db.Model):
__tablename__ = "visit_requests"
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = db.Column(
db.String(36),
db.ForeignKey("client_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
property_id = db.Column(
db.UUID(as_uuid=True),
db.ForeignKey("properties.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
message = db.Column(db.Text, nullable=False)
status = db.Column(db.String(20), nullable=False, default="pending")
scheduled_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
user = db.relationship(
"ClientUser", backref=db.backref("visit_requests", lazy="select")
)
property = db.relationship("Property", foreign_keys=[property_id], lazy="joined")

View file

1007
backend/app/routes/admin.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,92 @@
from flask import Blueprint, jsonify, request
from pydantic import ValidationError
from app.extensions import db
from app.models.agent import Agent
from app.schemas.agent import AgentIn, AgentOut
from app.utils.auth import require_admin
agents_public_bp = Blueprint("agents_public", __name__, url_prefix="/api/v1")
agents_admin_bp = Blueprint("agents_admin", __name__, url_prefix="/api/v1/admin")
# ── Public ────────────────────────────────────────────────────────────────────
@agents_public_bp.get("/agents")
def list_agents():
agents = (
Agent.query.filter_by(is_active=True)
.order_by(Agent.display_order.asc(), Agent.id.asc())
.all()
)
return jsonify([AgentOut.model_validate(a).model_dump() for a in agents])
# ── Admin ─────────────────────────────────────────────────────────────────────
@agents_admin_bp.get("/agents")
@require_admin
def admin_list_agents():
agents = Agent.query.order_by(Agent.display_order.asc(), Agent.id.asc()).all()
return jsonify([AgentOut.model_validate(a).model_dump() for a in agents])
@agents_admin_bp.post("/agents")
@require_admin
def admin_create_agent():
data = request.get_json(silent=True) or {}
try:
agent_in = AgentIn.model_validate(data)
except ValidationError as exc:
return jsonify({"error": "Dados inválidos", "details": exc.errors()}), 422
agent = Agent(
name=agent_in.name,
photo_url=agent_in.photo_url,
creci=agent_in.creci,
email=agent_in.email,
phone=agent_in.phone,
bio=agent_in.bio,
is_active=agent_in.is_active,
display_order=agent_in.display_order,
)
db.session.add(agent)
db.session.commit()
return jsonify(AgentOut.model_validate(agent).model_dump()), 201
@agents_admin_bp.put("/agents/<int:agent_id>")
@require_admin
def admin_update_agent(agent_id: int):
agent = Agent.query.get(agent_id)
if agent is None:
return jsonify({"error": "Corretor não encontrado"}), 404
data = request.get_json(silent=True) or {}
try:
agent_in = AgentIn.model_validate(data)
except ValidationError as exc:
return jsonify({"error": "Dados inválidos", "details": exc.errors()}), 422
agent.name = agent_in.name
agent.photo_url = agent_in.photo_url
agent.creci = agent_in.creci
agent.email = agent_in.email
agent.phone = agent_in.phone
agent.bio = agent_in.bio
agent.is_active = agent_in.is_active
agent.display_order = agent_in.display_order
db.session.commit()
return jsonify(AgentOut.model_validate(agent).model_dump())
@agents_admin_bp.delete("/agents/<int:agent_id>")
@require_admin
def admin_delete_agent(agent_id: int):
agent = Agent.query.get(agent_id)
if agent is None:
return jsonify({"error": "Corretor não encontrado"}), 404
agent.is_active = False
db.session.commit()
return jsonify({"message": "Corretor desativado com sucesso"})

View file

@ -0,0 +1,173 @@
import hashlib
import os
import re
from datetime import datetime, timedelta, timezone
from flask import Blueprint, request, jsonify
from sqlalchemy import func, text
from app.extensions import db
from app.models.page_view import PageView
from app.models.property import Property
from app.utils.auth import require_admin
analytics_bp = Blueprint("analytics", __name__)
# ─── Helpers ─────────────────────────────────────────────────────────────────
_PROPERTY_PATH_RE = re.compile(
r"^/api/v1/properties/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})",
re.IGNORECASE,
)
_SKIP_PREFIXES = ("/api/v1/admin", "/api/v1/auth", "/static")
def _should_track(path: str, method: str) -> bool:
if method != "GET":
return False
for prefix in _SKIP_PREFIXES:
if path.startswith(prefix):
return False
return True
def _ip_hash(ip: str) -> str:
salt = os.environ.get("IP_SALT", "default-salt")
return hashlib.sha256(f"{ip}{salt}".encode()).hexdigest()
def record_page_view(path: str, ip: str, user_agent: str) -> None:
"""Insert a page_view row. Silently swallows errors to never break requests."""
try:
match = _PROPERTY_PATH_RE.match(path)
property_id = match.group(1) if match else None
pv = PageView(
path=path,
property_id=property_id,
ip_hash=_ip_hash(ip),
user_agent=(user_agent or "")[:512],
)
db.session.add(pv)
db.session.commit()
except Exception:
db.session.rollback()
# ─── Admin endpoints ──────────────────────────────────────────────────────────
def _period_start(days: int) -> datetime:
return datetime.now(timezone.utc) - timedelta(days=days)
@analytics_bp.get("/summary")
@require_admin
def analytics_summary():
"""Cards: total today / this week / this month + daily series."""
days = int(request.args.get("days", 30))
if days not in (7, 30, 90):
days = 30
now = datetime.now(timezone.utc)
start_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
start_week = start_day - timedelta(days=now.weekday())
start_month = start_day.replace(day=1)
start_period = _period_start(days)
def count_since(dt: datetime) -> int:
return (
db.session.query(func.count(PageView.id))
.filter(PageView.accessed_at >= dt)
.scalar()
or 0
)
# daily series for sparkline/chart
daily = (
db.session.query(
func.date_trunc("day", PageView.accessed_at).label("day"),
func.count(PageView.id).label("views"),
)
.filter(PageView.accessed_at >= start_period)
.group_by(text("day"))
.order_by(text("day"))
.all()
)
series = [
{"date": row.day.strftime("%Y-%m-%d"), "views": row.views} for row in daily
]
return jsonify(
{
"today": count_since(start_day),
"this_week": count_since(start_week),
"this_month": count_since(start_month),
"period_days": days,
"period_total": count_since(start_period),
"series": series,
}
)
@analytics_bp.get("/top-pages")
@require_admin
def analytics_top_pages():
"""Top 10 most-viewed paths in the given period."""
days = int(request.args.get("days", 30))
if days not in (7, 30, 90):
days = 30
rows = (
db.session.query(PageView.path, func.count(PageView.id).label("views"))
.filter(PageView.accessed_at >= _period_start(days))
.group_by(PageView.path)
.order_by(func.count(PageView.id).desc())
.limit(10)
.all()
)
return jsonify([{"path": r.path, "views": r.views} for r in rows])
@analytics_bp.get("/top-properties")
@require_admin
def analytics_top_properties():
"""Top 10 most-viewed properties in the given period."""
days = int(request.args.get("days", 30))
if days not in (7, 30, 90):
days = 30
rows = (
db.session.query(PageView.property_id, func.count(PageView.id).label("views"))
.filter(
PageView.accessed_at >= _period_start(days),
PageView.property_id.isnot(None),
)
.group_by(PageView.property_id)
.order_by(func.count(PageView.id).desc())
.limit(10)
.all()
)
result = []
for row in rows:
prop = db.session.get(Property, row.property_id)
if prop:
photos = prop.photos or []
result.append(
{
"property_id": row.property_id,
"views": row.views,
"title": prop.title,
"cover": photos[0] if photos else None,
"city": prop.city.name if prop.city else None,
"neighborhood": (
prop.neighborhood.name if prop.neighborhood else None
),
}
)
return jsonify(result)

100
backend/app/routes/auth.py Normal file
View file

@ -0,0 +1,100 @@
import bcrypt
import jwt
from datetime import datetime, timedelta, timezone
from flask import Blueprint, request, jsonify, g, current_app
from pydantic import ValidationError
from app.extensions import db
from app.models.user import ClientUser
from app.schemas.auth import RegisterIn, LoginIn, UserOut, AuthTokenOut
from app.utils.auth import require_auth
auth_bp = Blueprint("auth", __name__)
_DUMMY_HASH = bcrypt.hashpw(b"dummy-password-for-timing", bcrypt.gensalt())
def _generate_token(user_id: str, secret: str) -> str:
payload = {
"sub": user_id,
"exp": datetime.now(timezone.utc) + timedelta(days=7),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, secret, algorithm="HS256")
@auth_bp.post("/register")
def register():
try:
data = RegisterIn.model_validate(request.get_json() or {})
except ValidationError as e:
return jsonify({"error": e.errors(include_url=False)}), 422
existing = ClientUser.query.filter_by(email=data.email).first()
if existing:
return jsonify({"error": "E-mail já cadastrado"}), 409
pwd_hash = bcrypt.hashpw(data.password.encode(), bcrypt.gensalt()).decode()
user = ClientUser(
name=data.name,
email=data.email,
password_hash=pwd_hash,
phone=data.phone,
whatsapp=data.whatsapp,
cpf=data.cpf,
birth_date=data.birth_date,
address_street=data.address_street,
address_number=data.address_number,
address_complement=data.address_complement,
address_neighborhood=data.address_neighborhood,
address_city=data.address_city,
address_state=data.address_state,
address_zip=data.address_zip,
)
db.session.add(user)
db.session.commit()
token = _generate_token(user.id, current_app.config["JWT_SECRET_KEY"])
user_out = UserOut.model_validate(user)
return (
jsonify(
AuthTokenOut(access_token=token, user=user_out).model_dump(mode="json")
),
201,
)
@auth_bp.post("/login")
def login():
try:
data = LoginIn.model_validate(request.get_json() or {})
except ValidationError as e:
return jsonify({"error": e.errors(include_url=False)}), 422
user = ClientUser.query.filter_by(email=data.email).first()
# Always run bcrypt to prevent timing attacks / email enumeration (SC-006)
candidate_hash = user.password_hash.encode() if user else _DUMMY_HASH
password_ok = bcrypt.checkpw(data.password.encode(), candidate_hash)
if not user or not password_ok:
return jsonify({"error": "E-mail ou senha inválidos"}), 401
token = _generate_token(user.id, current_app.config["JWT_SECRET_KEY"])
user_out = UserOut.model_validate(user)
return (
jsonify(
AuthTokenOut(access_token=token, user=user_out).model_dump(mode="json")
),
200,
)
@auth_bp.get("/me")
@require_auth
def me():
user = ClientUser.query.get(g.current_user_id)
if not user:
return jsonify({"error": "Não autorizado."}), 401
user_out = UserOut.model_validate(user)
return jsonify(user_out.model_dump(mode="json")), 200

View file

@ -0,0 +1,63 @@
from flask import Blueprint, jsonify
from sqlalchemy import func
from app.extensions import db
from app.models.catalog import Amenity, PropertyType
from app.models.imobiliaria import Imobiliaria
from app.models.property import Property
from app.schemas.catalog import AmenityOut, ImobiliariaOut, PropertyTypeOut
catalog_bp = Blueprint("catalog", __name__, url_prefix="/api/v1")
@catalog_bp.get("/property-types")
def list_property_types():
"""Return top-level categories with their subtypes nested."""
# Build count_map for subtypes (leaf nodes with parent_id IS NOT NULL)
rows = (
db.session.query(PropertyType.id, func.count(Property.id).label("cnt"))
.filter(PropertyType.parent_id.isnot(None))
.outerjoin(
Property,
(Property.subtype_id == PropertyType.id) & (Property.is_active.is_(True)),
)
.group_by(PropertyType.id)
.all()
)
count_map: dict[int, int] = {row.id: row.cnt for row in rows}
categories = (
PropertyType.query.filter_by(parent_id=None).order_by(PropertyType.id).all()
)
def serialize_category(cat) -> dict:
data = PropertyTypeOut.model_validate(cat).model_dump(mode="json")
data["subtypes"] = [
{**sub, "property_count": count_map.get(sub["id"], 0)}
for sub in data["subtypes"]
]
return data
return jsonify([serialize_category(c) for c in categories])
@catalog_bp.get("/amenities")
def list_amenities():
"""Return all amenities grouped."""
amenities = Amenity.query.order_by(Amenity.group, Amenity.name).all()
return jsonify(
[AmenityOut.model_validate(a).model_dump(mode="json") for a in amenities]
)
@catalog_bp.get("/imobiliarias")
def list_imobiliarias():
"""Return active imobiliárias ordered by display_order."""
items = (
Imobiliaria.query.filter_by(is_active=True)
.order_by(Imobiliaria.display_order, Imobiliaria.name)
.all()
)
return jsonify(
[ImobiliariaOut.model_validate(i).model_dump(mode="json") for i in items]
)

View file

@ -0,0 +1,109 @@
import uuid as _uuid
from flask import Blueprint, request, jsonify, g
from pydantic import ValidationError
from sqlalchemy.exc import IntegrityError
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.schemas.client_area import (
SavedPropertyOut,
FavoriteIn,
VisitRequestOut,
BoletoOut,
)
from app.utils.auth import require_auth
client_bp = Blueprint("client", __name__)
@client_bp.get("/favorites")
@require_auth
def get_favorites():
saved = (
SavedProperty.query.filter_by(user_id=g.current_user_id)
.order_by(SavedProperty.created_at.desc())
.all()
)
return (
jsonify(
[SavedPropertyOut.model_validate(s).model_dump(mode="json") for s in saved]
),
200,
)
@client_bp.post("/favorites")
@require_auth
def add_favorite():
try:
data = FavoriteIn.model_validate(request.get_json() or {})
except ValidationError as e:
return jsonify({"error": e.errors(include_url=False)}), 422
try:
prop_uuid = _uuid.UUID(data.property_id)
except ValueError:
return jsonify({"error": "property_id inválido"}), 422
prop = db.session.get(Property, prop_uuid)
if not prop:
return jsonify({"error": "Imóvel não encontrado"}), 404
saved = SavedProperty(user_id=g.current_user_id, property_id=prop_uuid)
db.session.add(saved)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
return jsonify({"error": "Imóvel já está nos favoritos"}), 409
return jsonify(SavedPropertyOut.model_validate(saved).model_dump(mode="json")), 201
@client_bp.delete("/favorites/<property_id>")
@require_auth
def remove_favorite(property_id: str):
try:
prop_uuid = _uuid.UUID(property_id)
except ValueError:
return jsonify({"error": "property_id inválido"}), 422
saved = SavedProperty.query.filter_by(
user_id=g.current_user_id, property_id=prop_uuid
).first()
if not saved:
return jsonify({"error": "Favorito não encontrado"}), 404
db.session.delete(saved)
db.session.commit()
return "", 204
@client_bp.get("/visits")
@require_auth
def get_visits():
visits = (
VisitRequest.query.filter_by(user_id=g.current_user_id)
.order_by(VisitRequest.created_at.desc())
.all()
)
return (
jsonify(
[VisitRequestOut.model_validate(v).model_dump(mode="json") for v in visits]
),
200,
)
@client_bp.get("/boletos")
@require_auth
def get_boletos():
boletos = (
Boleto.query.filter_by(user_id=g.current_user_id)
.order_by(Boleto.due_date.asc())
.all()
)
return (
jsonify([BoletoOut.model_validate(b).model_dump(mode="json") for b in boletos]),
200,
)

View file

@ -0,0 +1,11 @@
import os
from flask import Blueprint, jsonify
config_bp = Blueprint("config", __name__)
@config_bp.get("/api/v1/config/whatsapp")
def get_whatsapp_config():
"""Returns the configured WhatsApp number (no auth required)."""
number = os.environ.get("WHATSAPP_NUMBER", "")
return jsonify({"whatsapp_number": number})

View file

@ -0,0 +1,14 @@
from flask import Blueprint, jsonify
from app.models.homepage import HomepageConfig
from app.schemas.homepage import HomepageConfigOut
homepage_bp = Blueprint("homepage", __name__, url_prefix="/api/v1")
@homepage_bp.get("/homepage-config")
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())

View file

@ -0,0 +1,155 @@
import unicodedata
import re
from flask import Blueprint, jsonify, request
from sqlalchemy import func
from app.extensions import db
from app.models.location import City, Neighborhood
from app.models.property import Property
from app.schemas.catalog import CityOut, NeighborhoodOut
locations_bp = Blueprint("locations", __name__, url_prefix="/api/v1")
def _slugify(text: str) -> str:
text = unicodedata.normalize("NFD", text)
text = "".join(c for c in text if unicodedata.category(c) != "Mn")
text = text.lower()
text = re.sub(r"[^a-z0-9\s-]", "", text)
text = re.sub(r"[\s/]+", "-", text.strip())
return text
# ── Public endpoints ──────────────────────────────────────────────────────────
@locations_bp.get("/cities")
def list_cities():
"""List all cities ordered by state + name, with property_count."""
rows = (
db.session.query(City, func.count(Property.id).label("cnt"))
.outerjoin(
Property,
(Property.city_id == City.id) & (Property.is_active.is_(True)),
)
.group_by(City.id)
.order_by(City.state, City.name)
.all()
)
return jsonify(
[
{**CityOut.model_validate(city).model_dump(), "property_count": cnt}
for city, cnt in rows
]
)
@locations_bp.get("/neighborhoods")
def list_neighborhoods():
"""List neighborhoods, optionally filtered by city_id, with property_count."""
city_id = request.args.get("city_id")
q = (
db.session.query(Neighborhood, func.count(Property.id).label("cnt"))
.outerjoin(
Property,
(Property.neighborhood_id == Neighborhood.id)
& (Property.is_active.is_(True)),
)
.group_by(Neighborhood.id)
)
if city_id:
try:
q = q.filter(Neighborhood.city_id == int(city_id))
except ValueError:
pass
rows = q.order_by(Neighborhood.name).all()
return jsonify(
[
{
**NeighborhoodOut.model_validate(nbh).model_dump(),
"property_count": cnt,
}
for nbh, cnt in rows
]
)
# ── Admin endpoints ───────────────────────────────────────────────────────────
@locations_bp.post("/admin/cities")
def create_city():
data = request.get_json(force=True) or {}
name = (data.get("name") or "").strip()
state = (data.get("state") or "").strip().upper()[:2]
if not name or not state:
return jsonify({"error": "name e state são obrigatórios"}), 400
slug = data.get("slug") or _slugify(name)
if City.query.filter_by(slug=slug).first():
return jsonify({"error": "Já existe uma cidade com esse slug"}), 409
city = City(name=name, slug=slug, state=state)
db.session.add(city)
db.session.commit()
return jsonify(CityOut.model_validate(city).model_dump()), 201
@locations_bp.put("/admin/cities/<int:city_id>")
def update_city(city_id: int):
city = City.query.get_or_404(city_id)
data = request.get_json(force=True) or {}
if "name" in data:
city.name = data["name"].strip()
if "state" in data:
city.state = data["state"].strip().upper()[:2]
if "slug" in data:
city.slug = data["slug"].strip()
db.session.commit()
return jsonify(CityOut.model_validate(city).model_dump())
@locations_bp.delete("/admin/cities/<int:city_id>")
def delete_city(city_id: int):
city = City.query.get_or_404(city_id)
db.session.delete(city)
db.session.commit()
return "", 204
@locations_bp.post("/admin/neighborhoods")
def create_neighborhood():
data = request.get_json(force=True) or {}
name = (data.get("name") or "").strip()
city_id = data.get("city_id")
if not name or not city_id:
return jsonify({"error": "name e city_id são obrigatórios"}), 400
city = City.query.get_or_404(int(city_id))
slug = data.get("slug") or _slugify(name)
if Neighborhood.query.filter_by(slug=slug, city_id=city.id).first():
return jsonify({"error": "Já existe um bairro com esse slug nessa cidade"}), 409
neighborhood = Neighborhood(name=name, slug=slug, city_id=city.id)
db.session.add(neighborhood)
db.session.commit()
return jsonify(NeighborhoodOut.model_validate(neighborhood).model_dump()), 201
@locations_bp.put("/admin/neighborhoods/<int:neighborhood_id>")
def update_neighborhood(neighborhood_id: int):
n = Neighborhood.query.get_or_404(neighborhood_id)
data = request.get_json(force=True) or {}
if "name" in data:
n.name = data["name"].strip()
if "slug" in data:
n.slug = data["slug"].strip()
if "city_id" in data:
n.city_id = int(data["city_id"])
db.session.commit()
return jsonify(NeighborhoodOut.model_validate(n).model_dump())
@locations_bp.delete("/admin/neighborhoods/<int:neighborhood_id>")
def delete_neighborhood(neighborhood_id: int):
n = Neighborhood.query.get_or_404(neighborhood_id)
db.session.delete(n)
db.session.commit()
return "", 204

View file

@ -0,0 +1,262 @@
import math
from flask import Blueprint, jsonify, request
from pydantic import ValidationError
from sqlalchemy import and_, or_
from sqlalchemy.orm import aliased
from app.extensions import db
from app.models.catalog import Amenity, property_amenity
from app.models.location import Neighborhood
from app.models.property import Property
from app.schemas.property import PaginatedPropertiesOut, PropertyDetailOut, PropertyOut
properties_bp = Blueprint("properties", __name__, url_prefix="/api/v1")
def _parse_int(value: str | None, default: int | None = None) -> int | None:
if value is None:
return default
try:
return int(value)
except (ValueError, TypeError):
return default
def _parse_float(value: str | None, default: float | None = None) -> float | None:
if value is None:
return default
try:
return float(value)
except (ValueError, TypeError):
return default
@properties_bp.get("/properties")
def list_properties():
args = request.args
# ── Featured shortcut (homepage) ────────────────────────────────────────
if args.get("featured", "").lower() == "true":
from app.models.homepage import HomepageConfig
config = HomepageConfig.query.first()
limit = config.featured_properties_limit if config else 6
props = (
Property.query.filter_by(is_active=True, is_featured=True)
.order_by(Property.created_at.desc())
.limit(limit)
.all()
)
return jsonify(
[PropertyOut.model_validate(p).model_dump(mode="json") for p in props]
)
# ── Base query ───────────────────────────────────────────────────────────
query = Property.query.filter_by(is_active=True)
# Listing type (venda | aluguel)
listing_type = args.get("listing_type", "").lower()
if listing_type in ("venda", "aluguel"):
query = query.filter(Property.type == listing_type)
# Subtype — supports subtype_ids (comma-sep) or legacy subtype_id
subtype_ids_raw = args.get("subtype_ids", "")
if subtype_ids_raw:
try:
subtype_ids_list = [int(x) for x in subtype_ids_raw.split(",") if x.strip()]
except ValueError:
subtype_ids_list = []
if subtype_ids_list:
query = query.filter(Property.subtype_id.in_(subtype_ids_list))
else:
subtype_id = _parse_int(args.get("subtype_id"))
if subtype_id is not None:
query = query.filter(Property.subtype_id == subtype_id)
# City
city_id = _parse_int(args.get("city_id"))
if city_id is not None:
query = query.filter(Property.city_id == city_id)
# Neighborhood — supports neighborhood_ids (comma-sep) or legacy neighborhood_id
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()]
except ValueError:
neighborhood_ids_list = []
if neighborhood_ids_list:
query = query.filter(Property.neighborhood_id.in_(neighborhood_ids_list))
else:
neighborhood_id = _parse_int(args.get("neighborhood_id"))
if neighborhood_id is not None:
query = query.filter(Property.neighborhood_id == neighborhood_id)
# Imobiliária
imobiliaria_id = _parse_int(args.get("imobiliaria_id"))
if imobiliaria_id is not None:
query = query.filter(Property.imobiliaria_id == imobiliaria_id)
# Price range
price_min = _parse_float(args.get("price_min"))
price_max = _parse_float(args.get("price_max"))
include_condo = args.get("include_condo", "").lower() == "true"
if price_min is not None or price_max is not None:
if include_condo:
# Effective price = price + coalesce(condo_fee, 0)
from sqlalchemy import func
effective = Property.price + func.coalesce(Property.condo_fee, 0)
else:
effective = Property.price
if price_min is not None:
query = query.filter(effective >= price_min)
if price_max is not None:
query = query.filter(effective <= price_max)
# Bedrooms
bedrooms_min = _parse_int(args.get("bedrooms_min"))
bedrooms_max = _parse_int(args.get("bedrooms_max"))
if bedrooms_min is not None:
query = query.filter(Property.bedrooms >= bedrooms_min)
if bedrooms_max is not None:
query = query.filter(Property.bedrooms <= bedrooms_max)
# Bathrooms
bathrooms_min = _parse_int(args.get("bathrooms_min"))
bathrooms_max = _parse_int(args.get("bathrooms_max"))
if bathrooms_min is not None:
query = query.filter(Property.bathrooms >= bathrooms_min)
if bathrooms_max is not None:
query = query.filter(Property.bathrooms <= bathrooms_max)
# Parking spots
parking_min = _parse_int(args.get("parking_min"))
parking_max = _parse_int(args.get("parking_max"))
if parking_min is not None:
query = query.filter(Property.parking_spots >= parking_min)
if parking_max is not None:
query = query.filter(Property.parking_spots <= parking_max)
# Area m²
area_min = _parse_int(args.get("area_min"))
area_max = _parse_int(args.get("area_max"))
if area_min is not None:
query = query.filter(Property.area_m2 >= area_min)
if area_max is not None:
query = query.filter(Property.area_m2 <= area_max)
# Amenities (AND logic — property must have ALL selected amenities)
amenity_ids_raw = args.get("amenity_ids", "")
if amenity_ids_raw:
try:
amenity_ids = [int(x) for x in amenity_ids_raw.split(",") if x.strip()]
except ValueError:
amenity_ids = []
for aid in amenity_ids:
query = query.filter(
Property.id.in_(
property_amenity.select()
.where(property_amenity.c.amenity_id == aid)
.with_only_columns(property_amenity.c.property_id)
)
)
# ── Text search ──────────────────────────────────────────────────────────
q_raw = args.get("q", "").strip()[:200]
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),
)
)
)
# ── Sort ─────────────────────────────────────────────────────────────────
_sort_map = {
"price_asc": Property.price.asc(),
"price_desc": Property.price.desc(),
"area_desc": Property.area_m2.desc(),
"newest": Property.created_at.desc(),
"relevance": Property.created_at.desc(),
}
sort_key = args.get("sort", "relevance")
sort_order = _sort_map.get(sort_key, Property.created_at.desc())
# ── Pagination ───────────────────────────────────────────────────────────
page = max(1, _parse_int(args.get("page"), 1))
per_page = min(48, max(1, _parse_int(args.get("per_page"), 24)))
total = query.count()
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()
)
result = PaginatedPropertiesOut(
items=[PropertyOut.model_validate(p) for p in props],
total=total,
page=page,
per_page=per_page,
pages=pages,
)
return jsonify(result.model_dump(mode="json"))
@properties_bp.get("/properties/<slug>")
def get_property(slug: str):
prop = Property.query.filter_by(slug=slug, is_active=True).first()
if prop is None:
return jsonify({"error": "Im\u00f3vel n\u00e3o encontrado"}), 404
return jsonify(PropertyDetailOut.model_validate(prop).model_dump(mode="json"))
@properties_bp.post("/properties/<slug>/contact")
def contact_property(slug: str):
from app.models.lead import ContactLead
from app.schemas.lead import ContactLeadIn, ContactLeadCreatedOut
prop = Property.query.filter_by(slug=slug, is_active=True).first()
if prop is None:
return jsonify({"error": "Im\u00f3vel n\u00e3o encontrado"}), 404
data = request.get_json(silent=True) or {}
try:
lead_in = ContactLeadIn.model_validate(data)
except ValidationError as exc:
return jsonify({"error": "Dados inv\u00e1lidos", "details": exc.errors()}), 422
lead = ContactLead(
property_id=prop.id,
name=lead_in.name,
email=lead_in.email,
phone=lead_in.phone,
message=lead_in.message,
)
db.session.add(lead)
db.session.commit()
return (
jsonify(
ContactLeadCreatedOut(
id=lead.id, message="Mensagem enviada com sucesso!"
).model_dump()
),
201,
)

View file

View file

@ -0,0 +1,56 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
class AgentOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
photo_url: str | None
creci: str
email: str
phone: str
bio: str | None
is_active: bool
display_order: int
class AgentIn(BaseModel):
name: str
photo_url: str | None = None
creci: str
email: str
phone: str
bio: str | None = None
is_active: bool = True
display_order: int = 0
@field_validator("name")
@classmethod
def name_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("name não pode ser vazio")
return v.strip()
@field_validator("creci")
@classmethod
def creci_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("creci não pode ser vazio")
return v.strip()
@field_validator("email")
@classmethod
def email_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("email não pode ser vazio")
return v.strip()
@field_validator("phone")
@classmethod
def phone_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("phone não pode ser vazio")
return v.strip()

View file

@ -0,0 +1,68 @@
from pydantic import BaseModel, EmailStr, field_validator
from datetime import datetime, date
from typing import Optional
class RegisterIn(BaseModel):
name: str
email: EmailStr
password: str
# Campos opcionais — enriquecimento de perfil (feature 012)
phone: Optional[str] = None
whatsapp: Optional[str] = None
cpf: Optional[str] = None
birth_date: Optional[date] = None
address_street: Optional[str] = None
address_number: Optional[str] = None
address_complement: Optional[str] = None
address_neighborhood: Optional[str] = None
address_city: Optional[str] = None
address_state: Optional[str] = None
address_zip: Optional[str] = None
@field_validator("name")
@classmethod
def name_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Nome não pode ser vazio")
if len(v.strip()) < 2:
raise ValueError("Nome deve ter pelo menos 2 caracteres")
return v.strip()
@field_validator("email")
@classmethod
def email_lowercase(cls, v: str) -> str:
return v.lower().strip()
@field_validator("password")
@classmethod
def password_min_length(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Senha deve ter pelo menos 8 caracteres")
return v
class LoginIn(BaseModel):
email: EmailStr
password: str
@field_validator("email")
@classmethod
def email_lowercase(cls, v: str) -> str:
return v.lower().strip()
class UserOut(BaseModel):
id: str
name: str
email: str
role: str
created_at: datetime
model_config = {"from_attributes": True}
class AuthTokenOut(BaseModel):
access_token: str
user: UserOut

View file

@ -0,0 +1,58 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
class PropertyTypeOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
parent_id: int | None
subtypes: list["PropertyTypeOut"] = []
property_count: int = 0
# Required for Pydantic v2 to resolve the self-referential forward reference
PropertyTypeOut.model_rebuild()
class AmenityOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
group: str
class CityOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
state: str
property_count: int = 0
class NeighborhoodOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
city_id: int
property_count: int = 0
class ImobiliariaOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
logo_url: str | None
website: str | None
is_active: bool
display_order: int

View file

@ -0,0 +1,95 @@
from pydantic import BaseModel, field_validator
from datetime import datetime, date
from decimal import Decimal
from typing import Optional
import uuid
class PropertyBrief(BaseModel):
id: str
title: str
slug: str
model_config = {"from_attributes": True}
class SavedPropertyOut(BaseModel):
id: str
property_id: Optional[str]
property: Optional[PropertyBrief]
created_at: datetime
model_config = {"from_attributes": True}
class FavoriteIn(BaseModel):
property_id: str
@field_validator("property_id")
@classmethod
def validate_uuid(cls, v: str) -> str:
try:
uuid.UUID(v)
except ValueError:
raise ValueError("property_id deve ser um UUID válido")
return v
class VisitRequestOut(BaseModel):
id: str
property: Optional[PropertyBrief]
message: Optional[str]
status: str
scheduled_at: Optional[datetime]
created_at: datetime
model_config = {"from_attributes": True}
class VisitStatusIn(BaseModel):
status: str
scheduled_at: Optional[datetime] = None
@field_validator("status")
@classmethod
def validate_status(cls, v: str) -> str:
allowed = {"pending", "confirmed", "cancelled", "completed"}
if v not in allowed:
raise ValueError(f'Status deve ser um de: {", ".join(allowed)}')
return v
class BoletoOut(BaseModel):
id: str
property: Optional[PropertyBrief]
description: str
amount: Decimal
due_date: date
status: str
url: Optional[str]
created_at: datetime
model_config = {"from_attributes": True}
class BoletoCreateIn(BaseModel):
user_id: str
property_id: Optional[str] = None
description: str
amount: Decimal
due_date: date
url: Optional[str] = None
@field_validator("description")
@classmethod
def description_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Descrição não pode ser vazia")
return v.strip()
@field_validator("amount")
@classmethod
def amount_positive(cls, v: Decimal) -> Decimal:
if v <= 0:
raise ValueError("Valor deve ser positivo")
return v

View file

@ -0,0 +1,37 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, field_validator
class HomepageConfigOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
hero_headline: str
hero_subheadline: str | None
hero_cta_label: str
hero_cta_url: str
featured_properties_limit: int
hero_image_url: str | None = None
class HomepageConfigIn(BaseModel):
hero_headline: str
hero_subheadline: str | None = None
hero_cta_label: str = "Ver Imóveis"
hero_cta_url: str = "/imoveis"
featured_properties_limit: int = 6
hero_image_url: str | None = None
@field_validator("hero_headline")
@classmethod
def headline_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("hero_headline não pode ser vazio")
return v
@field_validator("featured_properties_limit")
@classmethod
def limit_in_range(cls, v: int) -> int:
if not (1 <= v <= 12):
raise ValueError("featured_properties_limit deve estar entre 1 e 12")
return v

View file

@ -0,0 +1,64 @@
from __future__ import annotations
import re
from pydantic import BaseModel, ConfigDict, field_validator
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
class ContactLeadIn(BaseModel):
name: str
email: str
phone: str | None = None
message: str
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
v = v.strip()
if len(v) < 2:
raise ValueError("Nome deve ter pelo menos 2 caracteres.")
if len(v) > 150:
raise ValueError("Nome deve ter no máximo 150 caracteres.")
return v
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
v = v.strip().lower()
if not _EMAIL_RE.match(v):
raise ValueError("E-mail inválido.")
if len(v) > 254:
raise ValueError("E-mail deve ter no máximo 254 caracteres.")
return v
@field_validator("phone")
@classmethod
def validate_phone(cls, v: str | None) -> str | None:
if v is None:
return v
v = v.strip()
if not v:
return None
if len(v) > 20:
raise ValueError("Telefone deve ter no máximo 20 caracteres.")
return v
@field_validator("message")
@classmethod
def validate_message(cls, v: str) -> str:
v = v.strip()
if len(v) < 10:
raise ValueError("Mensagem deve ter pelo menos 10 caracteres.")
if len(v) > 2000:
raise ValueError("Mensagem deve ter no máximo 2000 caracteres.")
return v
class ContactLeadCreatedOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
message: str

View file

@ -0,0 +1,57 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from uuid import UUID
from typing import Literal
from pydantic import BaseModel, ConfigDict
from app.schemas.catalog import AmenityOut, ImobiliariaOut, PropertyTypeOut, CityOut, NeighborhoodOut
class PropertyPhotoOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
url: str
alt_text: str
display_order: int
class PropertyOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
slug: str
code: str | None = None
price: Decimal
condo_fee: Decimal | None
iptu_anual: Decimal | None = None
type: Literal["venda", "aluguel"]
subtype: PropertyTypeOut | None
bedrooms: int
bathrooms: int
parking_spots: int
area_m2: int
city: CityOut | None
neighborhood: NeighborhoodOut | None
imobiliaria: ImobiliariaOut | None = None
is_featured: bool
created_at: datetime | None = None
photos: list[PropertyPhotoOut]
amenities: list[AmenityOut] = []
class PaginatedPropertiesOut(BaseModel):
items: list[PropertyOut]
total: int
page: int
per_page: int
pages: int
class PropertyDetailOut(PropertyOut):
address: str | None = None
code: str | None = None
description: str | None = None

View file

67
backend/app/utils/auth.py Normal file
View file

@ -0,0 +1,67 @@
from app.models.user import ClientUser
def require_admin(f):
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return jsonify({"error": "Não autorizado."}), 401
token = auth_header[7:]
try:
payload = jwt.decode(
token,
current_app.config["JWT_SECRET_KEY"],
algorithms=["HS256"],
)
user_id = payload.get("sub")
if not user_id:
return jsonify({"error": "Não autorizado."}), 401
user = ClientUser.query.get(user_id)
if not user or user.role != "admin":
return jsonify({"error": "Acesso restrito a administradores."}), 403
g.current_user_id = user_id
g.current_user = user
return f(*args, **kwargs)
except jwt.ExpiredSignatureError:
return jsonify({"error": "Não autorizado."}), 401
except jwt.InvalidTokenError:
return jsonify({"error": "Não autorizado."}), 401
return decorated
import jwt
from functools import wraps
from flask import request, g, current_app, jsonify
def require_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return jsonify({"error": "Não autorizado."}), 401
token = auth_header[7:]
try:
payload = jwt.decode(
token,
current_app.config["JWT_SECRET_KEY"],
algorithms=["HS256"],
)
user_id = payload.get("sub")
if not user_id:
return jsonify({"error": "Não autorizado."}), 401
g.current_user_id = user_id
return f(*args, **kwargs)
except jwt.ExpiredSignatureError:
return jsonify({"error": "Não autorizado."}), 401
except jwt.InvalidTokenError:
return jsonify({"error": "Não autorizado."}), 401
return decorated