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

5
backend/.env.example Normal file
View file

@ -0,0 +1,5 @@
DATABASE_URL=postgresql://imob:imob_dev@localhost:5432/saas_imobiliaria
SECRET_KEY=change-me-in-production
CORS_ORIGINS=http://localhost:5173
FLASK_ENV=development
FLASK_APP=app

25
backend/Dockerfile Normal file
View file

@ -0,0 +1,25 @@
FROM python:3.12-slim
# Install system deps needed by psycopg2-binary
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install uv
RUN pip install --no-cache-dir uv
WORKDIR /app
# Install Python deps first (layer cache)
COPY pyproject.toml .
RUN uv sync --no-dev
# Copy application code
COPY . .
# Make entrypoint executable
RUN chmod +x entrypoint.sh
EXPOSE 5000
ENTRYPOINT ["./entrypoint.sh"]

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

26
backend/entrypoint.sh Normal file
View file

@ -0,0 +1,26 @@
#!/bin/sh
set -e
echo "==> Aguardando banco de dados ficar disponível..."
# Flask-Migrate requer o banco para criar tabelas
until uv run python -c "
import os, psycopg2
conn = psycopg2.connect(os.environ['DATABASE_URL'])
conn.close()
print('Banco disponível.')
" 2>/dev/null; do
echo " Banco ainda não disponível, aguardando 2s..."
sleep 2
done
echo "==> Rodando migrações..."
uv run flask db upgrade heads
echo "==> Executando seeder (idempotente)..."
uv run python seeds/seed.py
echo "==> Importando imóveis do CSV (idempotente)..."
uv run python seeds/import_from_csv.py
echo "==> Iniciando servidor Flask..."
exec uv run python run.py

View file

@ -0,0 +1 @@
Single-database configuration for Flask.

View file

@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
backend/migrations/env.py Normal file
View file

@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,79 @@
"""add property types amenities parking condo fee
Revision ID: 64dcf652e0b5
Revises: f030e6aef123
Create Date: 2026-04-13 18:55:09.490479
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '64dcf652e0b5'
down_revision = 'f030e6aef123'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('amenities',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('slug', sa.String(length=120), nullable=False),
sa.Column('group', sa.Enum('caracteristica', 'lazer', 'condominio', 'seguranca', name='amenity_group'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('amenities', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_amenities_slug'), ['slug'], unique=True)
op.create_table('property_types',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('slug', sa.String(length=120), nullable=False),
sa.Column('parent_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['parent_id'], ['property_types.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('property_types', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_property_types_parent_id'), ['parent_id'], unique=False)
batch_op.create_index(batch_op.f('ix_property_types_slug'), ['slug'], unique=True)
op.create_table('property_amenity',
sa.Column('property_id', sa.UUID(), nullable=False),
sa.Column('amenity_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['amenity_id'], ['amenities.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['property_id'], ['properties.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('property_id', 'amenity_id')
)
with op.batch_alter_table('properties', schema=None) as batch_op:
batch_op.add_column(sa.Column('condo_fee', sa.Numeric(precision=10, scale=2), nullable=True))
batch_op.add_column(sa.Column('subtype_id', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('parking_spots', sa.Integer(), nullable=False))
batch_op.create_index(batch_op.f('ix_properties_subtype_id'), ['subtype_id'], unique=False)
batch_op.create_foreign_key(None, 'property_types', ['subtype_id'], ['id'], ondelete='SET NULL')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('properties', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_index(batch_op.f('ix_properties_subtype_id'))
batch_op.drop_column('parking_spots')
batch_op.drop_column('subtype_id')
batch_op.drop_column('condo_fee')
op.drop_table('property_amenity')
with op.batch_alter_table('property_types', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_property_types_slug'))
batch_op.drop_index(batch_op.f('ix_property_types_parent_id'))
op.drop_table('property_types')
with op.batch_alter_table('amenities', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_amenities_slug'))
op.drop_table('amenities')
# ### end Alembic commands ###

View file

@ -0,0 +1,42 @@
"""add client_users table
Revision ID: a1b2c3d4e5f6
Revises: ec0a90848eff
Create Date: 2026-04-13 20:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "a1b2c3d4e5f6"
down_revision = "ec0a90848eff"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"client_users",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=150), nullable=False),
sa.Column("email", sa.String(length=254), nullable=False),
sa.Column("password_hash", sa.String(length=100), nullable=False),
sa.Column(
"role", sa.String(length=20), nullable=False, server_default="client"
),
sa.Column(
"created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")
),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("client_users", schema=None) as batch_op:
batch_op.create_index("ix_client_users_email", ["email"], unique=True)
def downgrade():
with op.batch_alter_table("client_users", schema=None) as batch_op:
batch_op.drop_index("ix_client_users_email")
op.drop_table("client_users")

View file

@ -0,0 +1,52 @@
"""enrich_client_users
Revision ID: a2b3c4d5e6f7
Revises: f1a2b3c4d5e6
Create Date: 2026-04-14 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "a2b3c4d5e6f7"
down_revision = "f1a2b3c4d5e6"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("client_users") as batch_op:
batch_op.add_column(sa.Column("phone", sa.String(20), nullable=True))
batch_op.add_column(sa.Column("whatsapp", sa.String(20), nullable=True))
batch_op.add_column(sa.Column("cpf", sa.String(14), nullable=True))
batch_op.add_column(sa.Column("birth_date", sa.Date, nullable=True))
batch_op.add_column(sa.Column("address_street", sa.String(200), nullable=True))
batch_op.add_column(sa.Column("address_number", sa.String(20), nullable=True))
batch_op.add_column(
sa.Column("address_complement", sa.String(100), nullable=True)
)
batch_op.add_column(
sa.Column("address_neighborhood", sa.String(100), nullable=True)
)
batch_op.add_column(sa.Column("address_city", sa.String(100), nullable=True))
batch_op.add_column(sa.Column("address_state", sa.String(2), nullable=True))
batch_op.add_column(sa.Column("address_zip", sa.String(9), nullable=True))
batch_op.add_column(sa.Column("notes", sa.Text, nullable=True))
def downgrade():
with op.batch_alter_table("client_users") as batch_op:
batch_op.drop_column("notes")
batch_op.drop_column("address_zip")
batch_op.drop_column("address_state")
batch_op.drop_column("address_city")
batch_op.drop_column("address_neighborhood")
batch_op.drop_column("address_complement")
batch_op.drop_column("address_number")
batch_op.drop_column("address_street")
batch_op.drop_column("birth_date")
batch_op.drop_column("cpf")
batch_op.drop_column("whatsapp")
batch_op.drop_column("phone")

View file

@ -0,0 +1,117 @@
"""add saved_properties, visit_requests, boletos
Revision ID: b7c8d9e0f1a2
Revises: a1b2c3d4e5f6
Create Date: 2026-04-13 21:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "b7c8d9e0f1a2"
down_revision = "a1b2c3d4e5f6"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"saved_properties",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column(
"user_id",
sa.String(length=36),
sa.ForeignKey("client_users.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column(
"property_id",
sa.UUID(as_uuid=True),
sa.ForeignKey("properties.id", ondelete="SET NULL"),
nullable=True,
index=True,
),
sa.Column(
"created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")
),
sa.UniqueConstraint(
"user_id", "property_id", name="uq_saved_properties_user_property"
),
)
op.create_table(
"visit_requests",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column(
"user_id",
sa.String(length=36),
sa.ForeignKey("client_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
),
sa.Column(
"property_id",
sa.UUID(as_uuid=True),
sa.ForeignKey("properties.id", ondelete="SET NULL"),
nullable=True,
index=True,
),
sa.Column("message", sa.Text(), nullable=False),
sa.Column(
"status", sa.String(length=20), nullable=False, server_default="pending"
),
sa.Column("scheduled_at", sa.DateTime(), nullable=True),
sa.Column(
"created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")
),
)
op.create_table(
"boletos",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column(
"user_id",
sa.String(length=36),
sa.ForeignKey("client_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
),
sa.Column(
"property_id",
sa.UUID(as_uuid=True),
sa.ForeignKey("properties.id", ondelete="SET NULL"),
nullable=True,
index=True,
),
sa.Column("description", sa.String(length=200), nullable=False),
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
sa.Column("due_date", sa.Date(), nullable=False),
sa.Column(
"status", sa.String(length=20), nullable=False, server_default="pending"
),
sa.Column("url", sa.String(length=500), nullable=True),
sa.Column(
"created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")
),
)
def downgrade():
with op.batch_alter_table("boletos", schema=None) as batch_op:
batch_op.drop_index("ix_boletos_property_id")
batch_op.drop_index("ix_boletos_user_id")
op.drop_table("boletos")
with op.batch_alter_table("visit_requests", schema=None) as batch_op:
batch_op.drop_index("ix_visit_requests_property_id")
batch_op.drop_index("ix_visit_requests_user_id")
op.drop_table("visit_requests")
with op.batch_alter_table("saved_properties", schema=None) as batch_op:
batch_op.drop_index("ix_saved_properties_property_id")
batch_op.drop_index("ix_saved_properties_user_id")
op.drop_table("saved_properties")

View file

@ -0,0 +1,74 @@
"""add cities neighborhoods location filters
Revision ID: c33f6a342c20
Revises: 64dcf652e0b5
Create Date: 2026-04-13 19:32:35.258299
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c33f6a342c20'
down_revision = '64dcf652e0b5'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('cities',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('slug', sa.String(length=120), nullable=False),
sa.Column('state', sa.String(length=2), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('cities', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_cities_slug'), ['slug'], unique=True)
op.create_table('neighborhoods',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('slug', sa.String(length=120), nullable=False),
sa.Column('city_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['city_id'], ['cities.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('slug', 'city_id', name='uq_neighborhood_slug_city')
)
with op.batch_alter_table('neighborhoods', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_neighborhoods_city_id'), ['city_id'], unique=False)
batch_op.create_index(batch_op.f('ix_neighborhoods_slug'), ['slug'], unique=False)
with op.batch_alter_table('properties', schema=None) as batch_op:
batch_op.add_column(sa.Column('city_id', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('neighborhood_id', sa.Integer(), nullable=True))
batch_op.create_index(batch_op.f('ix_properties_city_id'), ['city_id'], unique=False)
batch_op.create_index(batch_op.f('ix_properties_neighborhood_id'), ['neighborhood_id'], unique=False)
batch_op.create_foreign_key(None, 'neighborhoods', ['neighborhood_id'], ['id'], ondelete='SET NULL')
batch_op.create_foreign_key(None, 'cities', ['city_id'], ['id'], ondelete='SET NULL')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('properties', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_index(batch_op.f('ix_properties_neighborhood_id'))
batch_op.drop_index(batch_op.f('ix_properties_city_id'))
batch_op.drop_column('neighborhood_id')
batch_op.drop_column('city_id')
with op.batch_alter_table('neighborhoods', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_neighborhoods_slug'))
batch_op.drop_index(batch_op.f('ix_neighborhoods_city_id'))
op.drop_table('neighborhoods')
with op.batch_alter_table('cities', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_cities_slug'))
op.drop_table('cities')
# ### end Alembic commands ###

View file

@ -0,0 +1,42 @@
"""add page_views table
Revision ID: c8d9e0f1a2b3
Revises: b7c8d9e0f1a2
Create Date: 2026-04-14 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "c8d9e0f1a2b3"
down_revision = "b7c8d9e0f1a2"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"page_views",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("path", sa.String(length=512), nullable=False),
sa.Column("property_id", sa.String(length=36), nullable=True),
sa.Column(
"accessed_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column("ip_hash", sa.String(length=64), nullable=True),
sa.Column("user_agent", sa.String(length=512), nullable=True),
)
op.create_index("ix_page_views_accessed_at", "page_views", ["accessed_at"])
op.create_index("ix_page_views_property_id", "page_views", ["property_id"])
def downgrade():
op.drop_index("ix_page_views_property_id", table_name="page_views")
op.drop_index("ix_page_views_accessed_at", table_name="page_views")
op.drop_table("page_views")

View file

@ -0,0 +1,23 @@
"""merge heads
Revision ID: d0e1f2a3b4c5
Revises: a2b3c4d5e6f7, c8d9e0f1a2b3
Create Date: 2026-04-17 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd0e1f2a3b4c5'
down_revision = ('a2b3c4d5e6f7', 'd1e2f3a4b5c6')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View file

@ -0,0 +1,27 @@
"""add hero_image_url to homepage_config
Revision ID: d1e2f3a4b5c6
Revises: c8d9e0f1a2b3
Create Date: 2026-04-18 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "d1e2f3a4b5c6"
down_revision = "c8d9e0f1a2b3"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"homepage_config",
sa.Column("hero_image_url", sa.String(length=512), nullable=True),
)
def downgrade():
op.drop_column("homepage_config", "hero_image_url")

View file

@ -0,0 +1,41 @@
"""add agents table
Revision ID: e1f2a3b4c5d6
Revises: d1e2f3a4b5c6
Create Date: 2026-04-17 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "e1f2a3b4c5d6"
down_revision = "d1e2f3a4b5c6"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"agents",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("photo_url", sa.String(length=512), nullable=True),
sa.Column("creci", sa.String(length=50), nullable=False),
sa.Column("email", sa.String(length=200), nullable=False),
sa.Column("phone", sa.String(length=30), nullable=False),
sa.Column("bio", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("display_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_agents_is_active", "agents", ["is_active"])
op.create_index("ix_agents_display_order", "agents", ["display_order"])
def downgrade():
op.drop_index("ix_agents_display_order", table_name="agents")
op.drop_index("ix_agents_is_active", table_name="agents")
op.drop_table("agents")

View file

@ -0,0 +1,34 @@
"""add parking_spots_covered to properties
Revision ID: e9f0a1b2c3d4
Revises: b7c8d9e0f1a2
Create Date: 2026-04-14 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "e9f0a1b2c3d4"
down_revision = "b7c8d9e0f1a2"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("properties", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"parking_spots_covered",
sa.Integer(),
nullable=False,
server_default="0",
)
)
def downgrade():
with op.batch_alter_table("properties", schema=None) as batch_op:
batch_op.drop_column("parking_spots_covered")

View file

@ -0,0 +1,54 @@
"""add contact_leads and property detail fields
Revision ID: ec0a90848eff
Revises: c33f6a342c20
Create Date: 2026-04-13 19:59:51.904013
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ec0a90848eff'
down_revision = 'c33f6a342c20'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('contact_leads',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('property_id', sa.UUID(), nullable=True),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('email', sa.String(length=254), nullable=False),
sa.Column('phone', sa.String(length=20), nullable=True),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['property_id'], ['properties.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('contact_leads', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_contact_leads_property_id'), ['property_id'], unique=False)
with op.batch_alter_table('properties', schema=None) as batch_op:
batch_op.add_column(sa.Column('code', sa.String(length=30), nullable=True))
batch_op.add_column(sa.Column('description', sa.Text(), nullable=True))
batch_op.create_unique_constraint(None, ['code'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('properties', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='unique')
batch_op.drop_column('description')
batch_op.drop_column('code')
with op.batch_alter_table('contact_leads', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_contact_leads_property_id'))
op.drop_table('contact_leads')
# ### end Alembic commands ###

View file

@ -0,0 +1,69 @@
"""initial schema: properties, property_photos, homepage_config
Revision ID: f030e6aef123
Revises:
Create Date: 2026-04-13 17:39:07.705944
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f030e6aef123'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('homepage_config',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('hero_headline', sa.String(length=120), nullable=False),
sa.Column('hero_subheadline', sa.String(length=240), nullable=True),
sa.Column('hero_cta_label', sa.String(length=40), nullable=False),
sa.Column('hero_cta_url', sa.String(length=200), nullable=False),
sa.Column('featured_properties_limit', sa.Integer(), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('properties',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('slug', sa.String(length=220), nullable=False),
sa.Column('address', sa.String(length=300), nullable=True),
sa.Column('price', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('type', sa.Enum('venda', 'aluguel', name='property_type'), nullable=False),
sa.Column('bedrooms', sa.Integer(), nullable=False),
sa.Column('bathrooms', sa.Integer(), nullable=False),
sa.Column('area_m2', sa.Integer(), nullable=False),
sa.Column('is_featured', sa.Boolean(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('properties', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_properties_slug'), ['slug'], unique=True)
op.create_table('property_photos',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('property_id', sa.UUID(), nullable=False),
sa.Column('url', sa.String(length=500), nullable=False),
sa.Column('alt_text', sa.String(length=200), nullable=False),
sa.Column('display_order', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['property_id'], ['properties.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('property_photos')
with op.batch_alter_table('properties', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_properties_slug'))
op.drop_table('properties')
op.drop_table('homepage_config')
# ### end Alembic commands ###

View file

@ -0,0 +1,28 @@
"""add iptu_anual to properties
Revision ID: f1a2b3c4d5e6
Revises: e9f0a1b2c3d4
Create Date: 2026-04-14 00:01:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "f1a2b3c4d5e6"
down_revision = "e9f0a1b2c3d4"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("properties", schema=None) as batch_op:
batch_op.add_column(
sa.Column("iptu_anual", sa.Numeric(precision=12, scale=2), nullable=True)
)
def downgrade():
with op.batch_alter_table("properties", schema=None) as batch_op:
batch_op.drop_column("iptu_anual")

View file

@ -0,0 +1,51 @@
"""merge heads and add imobiliarias table
Revision ID: f2a3b4c5d6e7
Revises: d0e1f2a3b4c5, e1f2a3b4c5d6
Create Date: 2026-04-18 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "f2a3b4c5d6e7"
down_revision = ("d0e1f2a3b4c5", "e1f2a3b4c5d6")
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"imobiliarias",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("logo_url", sa.String(length=512), nullable=True),
sa.Column("website", sa.String(length=512), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("display_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("id"),
)
op.add_column(
"properties",
sa.Column(
"imobiliaria_id",
sa.Integer(),
sa.ForeignKey("imobiliarias.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index("ix_properties_imobiliaria_id", "properties", ["imobiliaria_id"])
def downgrade():
op.drop_index("ix_properties_imobiliaria_id", table_name="properties")
op.drop_column("properties", "imobiliaria_id")
op.drop_table("imobiliarias")

29
backend/pyproject.toml Normal file
View file

@ -0,0 +1,29 @@
[project]
name = "saas-imobiliaria-backend"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"flask>=3.0",
"flask-sqlalchemy>=3.1",
"flask-migrate>=4.0",
"flask-cors>=4.0",
"pydantic>=2.7",
"pydantic[email]",
"psycopg2-binary>=2.9",
"python-dotenv>=1.0",
"PyJWT>=2.9",
"bcrypt>=4.2",
]
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-flask>=1.3",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]

6
backend/run.py Normal file
View file

@ -0,0 +1,6 @@
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)

View file

@ -0,0 +1,30 @@
codigo_csv,traducao,opcao_1,opcao_2,opcao_3,escolha
COLD_FLOOR,Piso frio,nova:caracteristica → piso-frio,ignorar,,
DIVIDERS,Divisórias,nova:caracteristica → divisorias,ignorar,,
FENCE,Cerca / Muro,nova:seguranca → cerca-muro,ignorar,,
FRUIT_TREES,Árvores frutíferas,nova:caracteristica → arvores-frutiferas,mapear → jardim,ignorar,
FULL_FLOOR,Andar corrido,nova:caracteristica → andar-corrido,ignorar,,
GARAGE,Garagem (estrutural),ignorar (já capturado em vagas_garagem),nova:caracteristica → garagem,,
INTEGRATED_ENVIRONMENTS,Ambientes integrados,nova:caracteristica → ambientes-integrados,ignorar,,
KITCHEN,Cozinha,ignorar (estrutural),nova:caracteristica → cozinha,,
LARGE_ROOM,Sala ampla,nova:caracteristica → sala-ampla,ignorar,,
LUNCH_ROOM,Sala de almoço,mapear → copa,nova:caracteristica → sala-de-almoco,ignorar,
MASSAGE_ROOM,Sala de massagem,nova:lazer → sala-de-massagem,mapear → spa,ignorar,
NEAR_ACCESS_ROADS,Próximo a vias de acesso,nova:localizacao → proximo-a-vias-de-acesso,ignorar,,
NEAR_HOSPITAL,Próximo a hospital,nova:localizacao → proximo-a-hospital,ignorar,,
NEAR_PUBLIC_TRANSPORT,Próximo a transporte público,nova:localizacao → proximo-a-transporte-publico,ignorar,,
NEAR_SCHOOL,Próximo a escola,nova:localizacao → proximo-a-escola,ignorar,,
NEAR_SHOPPING_CENTER,Próximo a shopping,nova:localizacao → proximo-a-shopping,ignorar,,
NUMBER_OF_FLOORS,Número de andares,ignorar (valor numérico),,,
PANTRY,Despensa,nova:caracteristica → despensa,ignorar,,
PARKING,Estacionamento,ignorar (já capturado em vagas_garagem),mapear → vaga-para-visitante,,
PLAN,Planta (tipo de layout),ignorar (não é amenidade),,,
RESTAURANT,Restaurante,nova:condominio → restaurante,ignorar,,
SANCA,Sanca (acabamento de gesso),nova:caracteristica → sanca,ignorar,,
SERVICE_ENTRANCE,Entrada de serviço,nova:caracteristica → entrada-de-servico,ignorar,,
SIDE_ENTRANCE,Entrada lateral,nova:caracteristica → entrada-lateral,ignorar,,
SLAB,Laje,nova:caracteristica → laje,ignorar,,
SMALL_ROOM,Cômodo pequeno,ignorar,nova:caracteristica → comodo-pequeno,,
SQUARE,Praça,nova:localizacao → praca,mapear → espaco-verde-parque,ignorar,
STAIR,Escada,ignorar (estrutural),nova:caracteristica → escada,,
TEEN_SPACE,Espaço teen,nova:lazer → espaco-teen,mapear → sala-de-jogos,ignorar,
1 codigo_csv traducao opcao_1 opcao_2 opcao_3 escolha
2 COLD_FLOOR Piso frio nova:caracteristica → piso-frio ignorar
3 DIVIDERS Divisórias nova:caracteristica → divisorias ignorar
4 FENCE Cerca / Muro nova:seguranca → cerca-muro ignorar
5 FRUIT_TREES Árvores frutíferas nova:caracteristica → arvores-frutiferas mapear → jardim ignorar
6 FULL_FLOOR Andar corrido nova:caracteristica → andar-corrido ignorar
7 GARAGE Garagem (estrutural) ignorar (já capturado em vagas_garagem) nova:caracteristica → garagem
8 INTEGRATED_ENVIRONMENTS Ambientes integrados nova:caracteristica → ambientes-integrados ignorar
9 KITCHEN Cozinha ignorar (estrutural) nova:caracteristica → cozinha
10 LARGE_ROOM Sala ampla nova:caracteristica → sala-ampla ignorar
11 LUNCH_ROOM Sala de almoço mapear → copa nova:caracteristica → sala-de-almoco ignorar
12 MASSAGE_ROOM Sala de massagem nova:lazer → sala-de-massagem mapear → spa ignorar
13 NEAR_ACCESS_ROADS Próximo a vias de acesso nova:localizacao → proximo-a-vias-de-acesso ignorar
14 NEAR_HOSPITAL Próximo a hospital nova:localizacao → proximo-a-hospital ignorar
15 NEAR_PUBLIC_TRANSPORT Próximo a transporte público nova:localizacao → proximo-a-transporte-publico ignorar
16 NEAR_SCHOOL Próximo a escola nova:localizacao → proximo-a-escola ignorar
17 NEAR_SHOPPING_CENTER Próximo a shopping nova:localizacao → proximo-a-shopping ignorar
18 NUMBER_OF_FLOORS Número de andares ignorar (valor numérico)
19 PANTRY Despensa nova:caracteristica → despensa ignorar
20 PARKING Estacionamento ignorar (já capturado em vagas_garagem) mapear → vaga-para-visitante
21 PLAN Planta (tipo de layout) ignorar (não é amenidade)
22 RESTAURANT Restaurante nova:condominio → restaurante ignorar
23 SANCA Sanca (acabamento de gesso) nova:caracteristica → sanca ignorar
24 SERVICE_ENTRANCE Entrada de serviço nova:caracteristica → entrada-de-servico ignorar
25 SIDE_ENTRANCE Entrada lateral nova:caracteristica → entrada-lateral ignorar
26 SLAB Laje nova:caracteristica → laje ignorar
27 SMALL_ROOM Cômodo pequeno ignorar nova:caracteristica → comodo-pequeno
28 SQUARE Praça nova:localizacao → praca mapear → espaco-verde-parque ignorar
29 STAIR Escada ignorar (estrutural) nova:caracteristica → escada
30 TEEN_SPACE Espaço teen nova:lazer → espaco-teen mapear → sala-de-jogos ignorar

12064
backend/seeds/data/fotos.csv Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,385 @@
"""
Importa imóveis de imoveis.csv + fotos.csv para o banco de dados.
Uso:
cd backend
uv run python seeds/import_from_csv.py [--csv-dir CAMINHO]
Padrão de CAMINHO: ../../aluguel_helper (relativo ao diretório backend/)
Idempotente: pula imóveis cujo `code` existe no banco.
"""
import argparse
import csv
import os
import re
import sys
import unicodedata
from collections import defaultdict
from decimal import Decimal
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app
from app.extensions import db
from app.models.location import City, Neighborhood
from app.models.property import Property, PropertyPhoto
from app.models.catalog import Amenity, PropertyType
from app.models.imobiliaria import Imobiliaria
# ── Configurações ─────────────────────────────────────────────────────────────
CITY_NAME = "Franca"
CITY_STATE = "SP"
# Mapeamento tipo_imovel (CSV) → slug do subtipo (já criado pelo seed.py)
SUBTYPE_MAP: dict[str, str] = {
"casa": "casa",
"apartamento": "apartamento",
"flat/studio": "flat",
"store": "loja-salao-ponto-comercial",
"landform": "terreno-lote-condominio",
}
# Mapeamento amenidade CSV (inglês) → slug português do banco
# Valores sem correspondência no sistema são ignorados (None).
AMENITY_MAP: dict[str, str | None] = {
# ── Características ───────────────────────────────────────────────────────
"PETS_ALLOWED": "aceita-animais",
"AIR_CONDITIONING": "ar-condicionado",
"SERVICE_AREA": "area-de-servico",
"BUILTIN_WARDROBE": "armario-embutido",
"BEDROOM_WARDROBE": "armario-embutido-no-quarto",
"KITCHEN_CABINETS": "armario-na-cozinha",
"BATHROOM_CABINETS": "armario-no-banheiro",
"BLINDEX_BOX": "box-blindex",
"CLOSET": "closet",
"DRESS_ROOM2": "closet",
"INTERNET_ACCESS": "conexao-a-internet",
"AMERICAN_KITCHEN": "cozinha-americana",
"LARGE_KITCHEN": "cozinha-americana",
"GOURMET_KITCHEN": "cozinha-gourmet",
"COOKER": "fogao",
"INTERCOM": "interfone",
"LARGE_WINDOW": "janela-grande",
"FURNISHED": "mobiliado",
"PLANNED_FURNITURE": "mobiliado",
"BACKYARD": "quintal",
"CABLE_TV": "tv-a-cabo",
"BALCONY": "varanda",
"WALL_BALCONY": "varanda",
"GOURMET_BALCONY": "varanda-gourmet",
"BARBECUE_BALCONY": "varanda-gourmet",
"NATURAL_VENTILATION": "ventilacao-natural",
"PANORAMIC_VIEW": "vista-panoramica",
"EXTERIOR_VIEW": "vista-panoramica",
"GAS_SHOWER": "aquecedor-a-gas",
"HEATING": "aquecimento-central",
"BATHTUB": "banheira",
"SERVICE_BATHROOM": "banheiro-de-servico",
"COPA": "copa",
"LUNCH_ROOM": "copa",
"EMPLOYEE_DEPENDENCY": "dependencia-de-empregada",
"DEPOSIT": "deposito",
"EDICULE": "edicula",
"SOLAR_ENERGY": "energia-solar",
"PET_SPACE": "espaco-pet",
"ENTRANCE_HALL": "hall-de-entrada",
"HOME_OFFICE": "home-office",
"CORNER_PROPERTY": "imovel-de-esquina",
"SOUNDPROOFING": "isolamento-acustico",
"ALUMINUM_WINDOW": "janela-de-aluminio",
"LAVABO": "lavabo",
"MEZZANINE": "mezanino",
"WOOD_FLOOR": "piso-de-madeira",
"LAMINATED_FLOOR": "piso-laminado",
"VINYL_FLOOR": "piso-vinilico",
"PORCELAIN": "porcelanato",
"DINNER_ROOM": "sala-de-jantar",
"PANTRY": "despensa",
"SERVICE_ENTRANCE": "entrada-de-servico",
"INTEGRATED_ENVIRONMENTS": "ambientes-integrados",
# ── Lazer ─────────────────────────────────────────────────────────────────
"FITNESS_ROOM": "academia",
"GYM": "academia",
"BAR": "bar",
"CINEMA": "cinema",
"BARBECUE_GRILL": "churrasqueira",
"PIZZA_OVEN": "churrasqueira",
"GOURMET_SPACE": "espaco-gourmet",
"GREEN_SPACE": "espaco-verde-parque",
"RECREATION_AREA": "espaco-verde-parque",
"WHIRLPOOL": "hidromassagem",
"GARDEN": "jardim",
"POOL": "piscina",
"PRIVATE_POOL": "piscina",
"ADULT_POOL": "piscina",
"HEATED_POOL": "piscina",
"CHILDRENS_POOL": "piscina",
"PLAYGROUND": "playground",
"TOYS_PLACE": "playground",
"SPORTS_COURT": "quadra-poliesportiva",
"FOOTBALL_FIELD": "quadra-poliesportiva",
"GAMES_ROOM": "sala-de-jogos",
"ADULT_GAME_ROOM": "sala-de-jogos",
"YOUTH_GAME_ROOM": "sala-de-jogos",
"TEEN_SPACE": "sala-de-jogos",
"PARTY_HALL": "salao-de-festas",
"SAUNA": "sauna",
"SPA": "spa",
"MASSAGE_ROOM": "spa",
# ── Condomínio ────────────────────────────────────────────────────────────
"DISABLED_ACCESS": "acesso-para-deficientes",
"BICYCLES_PLACE": "bicicletario",
"ELEVATOR": "elevador",
"ELECTRIC_GENERATOR": "gerador-eletrico",
"GRASS": "gramado",
"LAUNDRY": "lavanderia",
"RECEPTION": "recepcao",
"MEETING_ROOM": "sala-de-reunioes",
"COVENTION_HALL": "salao-de-convencoes",
"GUEST_PARKING": "vaga-para-visitante",
"RESTAURANT": "restaurante",
# ── Segurança ─────────────────────────────────────────────────────────────
"FENCE": "cerca-muro",
"SAFETY_CIRCUIT": "circuito-de-seguranca",
"SECURITY_CAMERA": "circuito-de-seguranca",
"GATED_COMMUNITY": "condominio-fechado",
"ELECTRONIC_GATE": "portao-eletronico",
"CONCIERGE_24H": "portaria-24h",
"ALARM_SYSTEM": "sistema-de-alarme",
"WATCHMAN": "vigia",
"PATROL": "vigia",
"SECURITY_CABIN": "vigia",
}
# ── Helpers ────────────────────────────────────────────────────────────────────
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
def _decimal_or_none(value: str) -> Decimal | None:
v = value.strip()
if not v:
return None
try:
return Decimal(v)
except Exception:
return None
def _int_or_zero(value: str) -> int:
v = value.strip()
if not v:
return 0
try:
return int(float(v))
except Exception:
return 0
# ── Leitura dos CSVs ───────────────────────────────────────────────────────────
def load_imoveis(csv_dir: str) -> list[dict]:
path = os.path.join(csv_dir, "imoveis.csv")
with open(path, encoding="utf-8", newline="") as f:
return list(csv.DictReader(f))
def load_fotos(csv_dir: str) -> dict[str, list[str]]:
"""Retorna {id_imovel: [url_ordenada_por_indice]}"""
path = os.path.join(csv_dir, "fotos.csv")
grouped: dict[str, list[tuple[int, str]]] = defaultdict(list)
with open(path, encoding="utf-8", newline="") as f:
for row in csv.DictReader(f):
grouped[row["id_imovel"]].append((int(row["indice"]), row["url"]))
return {
imovel_id: [url for _, url in sorted(fotos)]
for imovel_id, fotos in grouped.items()
}
# ── Importação ─────────────────────────────────────────────────────────────────
def importar(csv_dir: str) -> None:
app = create_app()
with app.app_context():
imoveis = load_imoveis(csv_dir)
fotos_map = load_fotos(csv_dir)
# ── Cidade Franca ─────────────────────────────────────────────────────
city_slug = _slugify(CITY_NAME)
city = City.query.filter_by(slug=city_slug).first()
if not city:
city = City(name=CITY_NAME, slug=city_slug, state=CITY_STATE)
db.session.add(city)
db.session.flush()
print(f" Cidade criada: {CITY_NAME}")
else:
print(f" Cidade existente: {CITY_NAME}")
# ── Cache de bairros ──────────────────────────────────────────────────
neighborhood_cache: dict[str, Neighborhood] = {
nbh.slug: nbh for nbh in Neighborhood.query.filter_by(city_id=city.id).all()
}
def get_or_create_neighborhood(name: str) -> Neighborhood:
slug = _slugify(name)
if slug not in neighborhood_cache:
nbh = Neighborhood(name=name, slug=slug, city_id=city.id)
db.session.add(nbh)
db.session.flush()
neighborhood_cache[slug] = nbh
return neighborhood_cache[slug]
# ── Cache de subtipos ─────────────────────────────────────────────────
subtype_cache: dict[str, PropertyType] = {
pt.slug: pt for pt in PropertyType.query.all()
}
# ── Cache de amenidades ───────────────────────────────────────────────
amenity_cache: dict[str, Amenity] = {a.slug: a for a in Amenity.query.all()}
# ── Cache de imobiliárias (cria se não existir) ───────────────────────
imob_cache: dict[str, Imobiliaria] = {
i.name: i for i in Imobiliaria.query.all()
}
def get_or_create_imobiliaria(name: str) -> Imobiliaria:
if name not in imob_cache:
imob = Imobiliaria(name=name, is_active=True, display_order=0)
db.session.add(imob)
db.session.flush()
imob_cache[name] = imob
print(f" Imobiliária criada: {name}")
return imob_cache[name]
# ── Imóveis existentes (por code) ─────────────────────────────────────
existing_codes: set[str] = {
p.code for p in Property.query.with_entities(Property.code).all() if p.code
}
created = 0
skipped = 0
updated = 0
for row in imoveis:
code = row["id"].strip()
titulo = row["titulo"].strip()
listing_type = "aluguel" if "alugar" in titulo.lower() else "venda"
if code in existing_codes:
# Corrige o type de registros já importados
rows_updated = (
Property.query.filter_by(code=code)
.update({"type": listing_type})
)
if rows_updated:
updated += 1
skipped += 1
continue
# Bairro
bairro_nome = row["bairro"].strip()
nbh = get_or_create_neighborhood(bairro_nome) if bairro_nome else None
# Subtipo
tipo_raw = row["tipo_imovel"].strip().lower()
subtype_slug = SUBTYPE_MAP.get(tipo_raw, "casa")
subtype = subtype_cache.get(subtype_slug)
if subtype is None:
# Fallback: pega qualquer subtipo residencial
subtype = subtype_cache.get("casa")
base_slug = _slugify(titulo)[:200]
slug = base_slug
suffix = 1
while Property.query.filter_by(slug=slug).first():
slug = f"{base_slug}-{suffix}"
suffix += 1
# Amenidades
amenidade_raw = row.get("amenidades", "").strip()
prop_amenities: list[Amenity] = []
if amenidade_raw:
for code_en in amenidade_raw.split("|"):
slug_pt = AMENITY_MAP.get(code_en.strip())
if slug_pt and slug_pt in amenity_cache:
amenity = amenity_cache[slug_pt]
if amenity not in prop_amenities:
prop_amenities.append(amenity)
# Imobiliária
imob_name = row.get("imobiliaria", "").strip()
imob = get_or_create_imobiliaria(imob_name) if imob_name else None
listing_type = "aluguel" if "alugar" in titulo.lower() else "venda"
prop = Property(
title=titulo,
slug=slug,
code=code,
description=row["descricao"].strip() or None,
address=row["endereco"].strip() or None,
price=Decimal(row["preco_aluguel"].strip()),
condo_fee=_decimal_or_none(row["condominio"]),
iptu_anual=_decimal_or_none(row["iptu"]),
type=listing_type,
subtype_id=subtype.id if subtype else None,
city_id=city.id,
neighborhood_id=nbh.id if nbh else None,
bedrooms=_int_or_zero(row["quartos"]),
bathrooms=_int_or_zero(row["banheiros"]),
parking_spots=_int_or_zero(row["vagas_garagem"]),
area_m2=_int_or_zero(row["area_m2"]) or 1,
is_featured=False,
is_active=True,
imobiliaria_id=imob.id if imob else None,
)
prop.amenities = prop_amenities
db.session.add(prop)
db.session.flush()
# Fotos
for order, url in enumerate(fotos_map.get(code, [])):
db.session.add(
PropertyPhoto(
property_id=prop.id,
url=url,
alt_text=titulo,
display_order=order,
)
)
existing_codes.add(code)
created += 1
db.session.commit()
print(
f"\nImportação concluída: {created} imóveis criados, "
f"{updated} tipo(s) atualizado(s), {skipped - updated} já existiam sem alteração."
)
# ── Entrypoint ─────────────────────────────────────────────────────────────────
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Importa imóveis do aluguel_helper.")
parser.add_argument(
"--csv-dir",
default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "data"),
help="Diretório contendo imoveis.csv e fotos.csv",
)
args = parser.parse_args()
csv_dir = os.path.abspath(args.csv_dir)
print(f"==> Lendo CSVs de: {csv_dir}")
importar(csv_dir)

798
backend/seeds/seed.py Normal file
View file

@ -0,0 +1,798 @@
"""
Popula o banco com dados de exemplo para desenvolvimento.
Uso: cd backend && uv run python seeds/seed.py
Idempotente: apaga e recria tudo a cada execução.
"""
import sys
import os
import unicodedata
import re
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import bcrypt
from app import create_app
from app.extensions import db
from app.models.homepage import HomepageConfig
from app.models.property import Property, PropertyPhoto
from app.models.catalog import Amenity, PropertyType
from app.models.location import City, Neighborhood
from app.models.user import ClientUser
from app.models.agent import Agent
# ── Property Types ────────────────────────────────────────────────────────────
PROPERTY_TYPES = [
{
"name": "Residencial",
"slug": "residencial",
"subtypes": [
{"name": "Apartamento", "slug": "apartamento"},
{"name": "Studio", "slug": "studio"},
{"name": "Kitnet", "slug": "kitnet"},
{"name": "Casa", "slug": "casa"},
{"name": "Casa de Condomínio", "slug": "casa-de-condominio"},
{"name": "Casa de Vila", "slug": "casa-de-vila"},
{"name": "Cobertura", "slug": "cobertura"},
{"name": "Flat", "slug": "flat"},
{"name": "Loft", "slug": "loft"},
{"name": "Terreno / Lote / Condomínio", "slug": "terreno-lote-condominio"},
{"name": "Sobrado", "slug": "sobrado"},
{"name": "Fazenda / Sítio / Chácara", "slug": "fazenda-sitio-chacara"},
],
},
{
"name": "Comercial",
"slug": "comercial",
"subtypes": [
{
"name": "Loja / Salão / Ponto Comercial",
"slug": "loja-salao-ponto-comercial",
},
{"name": "Conjunto Comercial / Sala", "slug": "conjunto-comercial-sala"},
{"name": "Casa Comercial", "slug": "casa-comercial"},
{"name": "Hotel / Motel / Pousada", "slug": "hotel-motel-pousada"},
{"name": "Andar / Laje Corporativa", "slug": "andar-laje-corporativa"},
{"name": "Prédio Inteiro", "slug": "predio-inteiro"},
{"name": "Terrenos / Lotes Comerciais", "slug": "terreno-lote-comercial"},
{"name": "Galpão / Depósito / Armazém", "slug": "galpao-deposito-armazem"},
{"name": "Garagem", "slug": "garagem"},
],
},
]
# ── Amenities ────────────────────────────────────────────────────────────────
AMENITIES: dict[str, list[str]] = {
"caracteristica": [
"Aceita animais",
"Aquecedor a gás",
"Aquecimento central",
"Ar-condicionado",
"Área de serviço",
"Armário embutido",
"Armário embutido no quarto",
"Armário na cozinha",
"Armário no banheiro",
"Banheira",
"Banheiro de serviço",
"Box blindex",
"Closet",
"Conexão à internet",
"Copa",
"Cozinha americana",
"Cozinha gourmet",
"Dependência de empregada",
"Depósito",
"Despensa",
"Edícula",
"Energia solar",
"Entrada de serviço",
"Espaço pet",
"Fogão",
"Hall de entrada",
"Home office",
"Imóvel de esquina",
"Interfone",
"Ambientes integrados",
"Isolamento acústico",
"Janela de alumínio",
"Janela grande",
"Lavabo",
"Mezanino",
"Mobiliado",
"Piso de madeira",
"Piso laminado",
"Piso vinílico",
"Porcelanato",
"Quintal",
"Sala de jantar",
"TV a cabo",
"Varanda",
"Varanda gourmet",
"Ventilação natural",
"Vista panorâmica",
],
"lazer": [
"Academia",
"Bar",
"Cinema",
"Churrasqueira",
"Espaço gourmet",
"Espaço verde / Parque",
"Hidromassagem",
"Jardim",
"Piscina",
"Playground",
"Quadra poliesportiva",
"Sala de jogos",
"Salão de festas",
"Sauna",
"Spa",
],
"condominio": [
"Acesso para deficientes",
"Bicicletário",
"Restaurante",
"Elevador",
"Gerador elétrico",
"Gramado",
"Lavanderia",
"Recepção",
"Sala de reuniões",
"Salão de convenções",
"Vaga para visitante",
],
"seguranca": [
"Cerca / Muro",
"Circuito de segurança",
"Condomínio fechado",
"Portão eletrônico",
"Portaria 24h",
"Sistema de alarme",
"Vigia",
],
}
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
# ── Agents ─────────────────────────────────────────────────────────────────────
SAMPLE_AGENTS = [
{
"name": "Carlos Eduardo Mota",
"photo_url": "https://randomuser.me/api/portraits/men/32.jpg",
"creci": "123456-F",
"email": "carlos.mota@imobiliaria.com",
"phone": "(11) 98765-4321",
"bio": "Especialista em imóveis residenciais de alto padrão na Grande São Paulo. Mais de 10 anos de experiência.",
"is_active": True,
"display_order": 1,
},
{
"name": "Fernanda Lima Souza",
"photo_url": "https://randomuser.me/api/portraits/women/44.jpg",
"creci": "234567-F",
"email": "fernanda.lima@imobiliaria.com",
"phone": "(11) 97654-3210",
"bio": "Formada em Administração com pós em Mercado Imobiliário. Foco em imóveis comerciais e corporativos.",
"is_active": True,
"display_order": 2,
},
{
"name": "Ricardo Alves Pereira",
"photo_url": "https://randomuser.me/api/portraits/men/51.jpg",
"creci": "345678-F",
"email": "ricardo.alves@imobiliaria.com",
"phone": "(11) 96543-2109",
"bio": "Especializado em lançamentos e imóveis na planta. Parceiro dos principais construtores da região.",
"is_active": True,
"display_order": 3,
},
{
"name": "Juliana Nascimento",
"photo_url": "https://randomuser.me/api/portraits/women/68.jpg",
"creci": "456789-F",
"email": "juliana.nascimento@imobiliaria.com",
"phone": "(11) 95432-1098",
"bio": "Apaixonada por conectar famílias ao imóvel perfeito. Especialista em Alphaville e Tamboré.",
"is_active": True,
"display_order": 4,
},
{
"name": "Marcos Vinícius Costa",
"photo_url": "https://randomuser.me/api/portraits/men/77.jpg",
"creci": "567890-F",
"email": "marcos.costa@imobiliaria.com",
"phone": "(11) 94321-0987",
"bio": "Corretor com expertise em locações comerciais e residenciais. Atendimento 100% personalizado.",
"is_active": True,
"display_order": 5,
},
{
"name": "Patrícia Rodrigues",
"photo_url": "https://randomuser.me/api/portraits/women/85.jpg",
"creci": "678901-F",
"email": "patricia.rodrigues@imobiliaria.com",
"phone": "(11) 93210-9876",
"bio": "Referência em imóveis de luxo. Fluente em inglês e espanhol para atendimento internacional.",
"is_active": True,
"display_order": 6,
},
{
"name": "Bruno Henrique Ferreira",
"photo_url": "https://randomuser.me/api/portraits/men/22.jpg",
"creci": "789012-F",
"email": "bruno.ferreira@imobiliaria.com",
"phone": "(11) 92109-8765",
"bio": "Especialista em avaliação de imóveis e consultoria de investimentos imobiliários.",
"is_active": True,
"display_order": 7,
},
{
"name": "Aline Mendes Torres",
"photo_url": "https://randomuser.me/api/portraits/women/12.jpg",
"creci": "890123-F",
"email": "aline.mendes@imobiliaria.com",
"phone": "(11) 91098-7654",
"bio": "Dedicada ao mercado de locação residencial. Processo ágil e desburocratizado do início ao fim.",
"is_active": True,
"display_order": 8,
},
]
# ── Cities & Neighborhoods ─────────────────────────────────────────────────────
LOCATIONS = [
{
"name": "São Paulo",
"slug": "sao-paulo",
"state": "SP",
"neighborhoods": [
{"name": "Centro", "slug": "centro"},
{"name": "Jardim Primavera", "slug": "jardim-primavera"},
{"name": "Vila Nova", "slug": "vila-nova"},
{"name": "Pinheiros", "slug": "pinheiros"},
{"name": "Itaim Bibi", "slug": "itaim-bibi"},
{"name": "Vila Olímpia", "slug": "vila-olimpia"},
{"name": "Centro Histórico", "slug": "centro-historico"},
{"name": "Zona Norte", "slug": "zona-norte"},
{"name": "Alto da Boa Vista", "slug": "alto-da-boa-vista"},
],
},
{
"name": "Rio de Janeiro",
"slug": "rio-de-janeiro",
"state": "RJ",
"neighborhoods": [
{"name": "Praia Grande", "slug": "praia-grande"},
{"name": "Copacabana", "slug": "copacabana"},
{"name": "Ipanema", "slug": "ipanema"},
{"name": "Barra da Tijuca", "slug": "barra-da-tijuca"},
],
},
{
"name": "Campinas",
"slug": "campinas",
"state": "SP",
"neighborhoods": [
{"name": "Alphaville", "slug": "alphaville"},
{"name": "Universitário", "slug": "universitario"},
],
},
]
# ── Sample Properties ─────────────────────────────────────────────────────────
SAMPLE_PROPERTIES = [
{
"title": "Apartamento 3 quartos — Centro",
"slug": "apartamento-3-quartos-centro",
"address": "Rua das Flores, 123 — Centro",
"code": "SP-001",
"description": "Excelente apartamento localizado no coração do Centro, com ótima iluminação natural e acabamento de qualidade. O imóvel conta com sala ampla, cozinha bem distribuída e quartos espaçosos com armários embutidos.\n\nPróximo a comércio, transporte público e todas as facilidades urbanas. Condomínio completo com elevador e portaria. Não perca esta oportunidade!",
"price": "750000.00",
"condo_fee": "800.00",
"type": "venda",
"subtype_slug": "apartamento",
"city_slug": "sao-paulo",
"neighborhood_slug": "centro",
"bedrooms": 3,
"bathrooms": 2,
"parking_spots": 1,
"area_m2": 98,
"is_featured": True,
"is_active": True,
"amenity_slugs": [
"ar-condicionado",
"armario-embutido",
"interfone",
"elevador",
],
"photos": [
{
"url": "https://picsum.photos/seed/apt1/800/450",
"alt_text": "Sala de estar ampla com varanda",
"display_order": 0,
}
],
},
{
"title": "Casa 4 quartos — Jardim Primavera",
"slug": "casa-4-quartos-jardim-primavera",
"address": "Av. das Palmeiras, 456 — Jardim Primavera",
"price": "1250000.00",
"condo_fee": None,
"type": "venda",
"subtype_slug": "casa",
"city_slug": "sao-paulo",
"neighborhood_slug": "jardim-primavera",
"bedrooms": 4,
"bathrooms": 3,
"parking_spots": 2,
"area_m2": 220,
"is_featured": True,
"is_active": True,
"amenity_slugs": ["piscina", "churrasqueira", "jardim", "quintal"],
"photos": [
{
"url": "https://picsum.photos/seed/house2/800/450",
"alt_text": "Fachada da casa com jardim",
"display_order": 0,
}
],
},
{
"title": "Studio moderno — Vila Nova",
"slug": "studio-moderno-vila-nova",
"address": "Rua dos Ipês, 789 — Vila Nova",
"price": "2800.00",
"condo_fee": "350.00",
"type": "aluguel",
"subtype_slug": "studio",
"city_slug": "sao-paulo",
"neighborhood_slug": "vila-nova",
"bedrooms": 1,
"bathrooms": 1,
"parking_spots": 0,
"area_m2": 38,
"is_featured": True,
"is_active": True,
"amenity_slugs": ["cozinha-americana", "mobiliado", "conexao-a-internet"],
"photos": [
{
"url": "https://picsum.photos/seed/studio3/800/450",
"alt_text": "Ambiente integrado do studio",
"display_order": 0,
}
],
},
{
"title": "Cobertura duplex — Beira Mar",
"slug": "cobertura-duplex-beira-mar",
"address": "Av. Beira Mar, 1000 — Praia Grande",
"price": "3200000.00",
"condo_fee": "1500.00",
"type": "venda",
"subtype_slug": "cobertura",
"city_slug": "rio-de-janeiro",
"neighborhood_slug": "praia-grande",
"bedrooms": 5,
"bathrooms": 4,
"parking_spots": 3,
"area_m2": 380,
"is_featured": True,
"is_active": True,
"amenity_slugs": [
"piscina",
"vista-panoramica",
"varanda-gourmet",
"cozinha-gourmet",
"closet",
],
"photos": [
{
"url": "https://picsum.photos/seed/cobertura4/800/450",
"alt_text": "Vista da cobertura para o mar",
"display_order": 0,
}
],
},
{
"title": "Kitnet — Próximo à Universidade",
"slug": "kitnet-proximo-universidade",
"address": "Rua Estudante, 50 — Universitário",
"price": "1500.00",
"condo_fee": "200.00",
"type": "aluguel",
"subtype_slug": "kitnet",
"city_slug": "campinas",
"neighborhood_slug": "universitario",
"bedrooms": 1,
"bathrooms": 1,
"parking_spots": 0,
"area_m2": 25,
"is_featured": True,
"is_active": True,
"amenity_slugs": ["mobiliado", "conexao-a-internet", "fogao"],
"photos": [
{
"url": "https://picsum.photos/seed/kitnet5/800/450",
"alt_text": "Interior da kitnet mobiliada",
"display_order": 0,
}
],
},
{
"title": "Sobrado 3 quartos — Bairro Nobre",
"slug": "sobrado-3-quartos-bairro-nobre",
"address": "Rua Nobreza, 321 — Alto da Boa Vista",
"price": "890000.00",
"condo_fee": None,
"type": "venda",
"subtype_slug": "sobrado",
"city_slug": "sao-paulo",
"neighborhood_slug": "alto-da-boa-vista",
"bedrooms": 3,
"bathrooms": 2,
"parking_spots": 2,
"area_m2": 160,
"is_featured": True,
"is_active": True,
"amenity_slugs": [
"churrasqueira",
"jardim",
"portao-eletronico",
"sistema-de-alarme",
],
"photos": [
{
"url": "https://picsum.photos/seed/sobrado6/800/450",
"alt_text": "Fachada do sobrado com garagem",
"display_order": 0,
}
],
},
{
"title": "Loft industrial — Centro Histórico",
"slug": "loft-industrial-centro-historico",
"address": "Rua da Consolação, 200 — Centro Histórico",
"price": "4200.00",
"condo_fee": "500.00",
"type": "aluguel",
"subtype_slug": "loft",
"city_slug": "sao-paulo",
"neighborhood_slug": "centro-historico",
"bedrooms": 1,
"bathrooms": 1,
"parking_spots": 1,
"area_m2": 65,
"is_featured": False,
"is_active": True,
"amenity_slugs": [
"ar-condicionado",
"mobiliado",
"conexao-a-internet",
"elevador",
],
"photos": [
{
"url": "https://picsum.photos/seed/loft7/800/450",
"alt_text": "Interior do loft",
"display_order": 0,
}
],
},
{
"title": "Apartamento 2 quartos — Pinheiros",
"slug": "apartamento-2-quartos-pinheiros",
"address": "Rua Teodoro Sampaio, 450 — Pinheiros",
"price": "580000.00",
"condo_fee": "650.00",
"type": "venda",
"subtype_slug": "apartamento",
"city_slug": "sao-paulo",
"neighborhood_slug": "pinheiros",
"bedrooms": 2,
"bathrooms": 1,
"parking_spots": 1,
"area_m2": 72,
"is_featured": False,
"is_active": True,
"amenity_slugs": [
"ar-condicionado",
"varanda",
"interfone",
"academia",
"elevador",
],
"photos": [
{
"url": "https://picsum.photos/seed/apt8/800/450",
"alt_text": "Sala com varanda",
"display_order": 0,
}
],
},
{
"title": "Casa de Condomínio — Alphaville",
"slug": "casa-condominio-alphaville",
"address": "Alameda Itu, 10 — Alphaville",
"price": "2100000.00",
"condo_fee": "1200.00",
"type": "venda",
"subtype_slug": "casa-de-condominio",
"city_slug": "campinas",
"neighborhood_slug": "alphaville",
"bedrooms": 4,
"bathrooms": 4,
"parking_spots": 4,
"area_m2": 350,
"is_featured": False,
"is_active": True,
"amenity_slugs": [
"piscina",
"churrasqueira",
"salao-de-festas",
"portaria-24h",
"condominio-fechado",
"playground",
],
"photos": [
{
"url": "https://picsum.photos/seed/cond9/800/450",
"alt_text": "Área de lazer do condomínio",
"display_order": 0,
}
],
},
{
"title": "Flat executivo — Itaim Bibi",
"slug": "flat-executivo-itaim-bibi",
"address": "Av. Faria Lima, 3000 — Itaim Bibi",
"price": "6500.00",
"condo_fee": "800.00",
"type": "aluguel",
"subtype_slug": "flat",
"city_slug": "sao-paulo",
"neighborhood_slug": "itaim-bibi",
"bedrooms": 1,
"bathrooms": 1,
"parking_spots": 1,
"area_m2": 45,
"is_featured": False,
"is_active": True,
"amenity_slugs": [
"ar-condicionado",
"mobiliado",
"recepcao",
"academia",
"elevador",
"portaria-24h",
],
"photos": [
{
"url": "https://picsum.photos/seed/flat10/800/450",
"alt_text": "Suite executiva",
"display_order": 0,
}
],
},
{
"title": "Terreno 600m² — Zona Norte",
"slug": "terreno-600m2-zona-norte",
"address": "Estrada das Amendoeiras, s/n — Zona Norte",
"price": "320000.00",
"condo_fee": None,
"type": "venda",
"subtype_slug": "terreno-lote-condominio",
"city_slug": "sao-paulo",
"neighborhood_slug": "zona-norte",
"bedrooms": 0,
"bathrooms": 0,
"parking_spots": 0,
"area_m2": 600,
"is_featured": False,
"is_active": True,
"amenity_slugs": [],
"photos": [
{
"url": "https://picsum.photos/seed/terreno11/800/450",
"alt_text": "Terreno plano",
"display_order": 0,
}
],
},
{
"title": "Conjunto Comercial 80m² — Vila Olímpia",
"slug": "conjunto-comercial-80m2-vila-olimpia",
"address": "Rua Funchal, 500 — Vila Olímpia",
"price": "8000.00",
"condo_fee": "1800.00",
"type": "aluguel",
"subtype_slug": "conjunto-comercial-sala",
"city_slug": "sao-paulo",
"neighborhood_slug": "vila-olimpia",
"bedrooms": 0,
"bathrooms": 2,
"parking_spots": 2,
"area_m2": 80,
"is_featured": False,
"is_active": True,
"amenity_slugs": ["ar-condicionado", "recepcao", "elevador", "portaria-24h"],
"photos": [
{
"url": "https://picsum.photos/seed/comercial12/800/450",
"alt_text": "Sala comercial",
"display_order": 0,
}
],
},
]
def seed() -> None:
app = create_app()
with app.app_context():
# ── Wipe existing data (FK order) ──────────────────────────────────────
db.session.execute(db.text("DELETE FROM property_amenity"))
db.session.query(PropertyPhoto).delete()
db.session.query(Property).delete()
db.session.query(Neighborhood).delete()
db.session.query(City).delete()
db.session.query(Amenity).delete()
# Delete subtypes before parents to satisfy self-referential FK
db.session.query(PropertyType).filter(
PropertyType.parent_id.isnot(None)
).delete()
db.session.query(PropertyType).delete()
db.session.query(HomepageConfig).delete()
db.session.commit()
# ── Property Types ──────────────────────────────────────────────────────
subtype_map: dict[str, PropertyType] = {}
for cat_data in PROPERTY_TYPES:
cat = PropertyType(name=cat_data["name"], slug=cat_data["slug"])
db.session.add(cat)
db.session.flush()
for sub_data in cat_data["subtypes"]:
sub = PropertyType(
name=sub_data["name"], slug=sub_data["slug"], parent_id=cat.id
)
db.session.add(sub)
db.session.flush()
subtype_map[sub.slug] = sub
# ── Amenities ───────────────────────────────────────────────────────────
amenity_map: dict[str, Amenity] = {}
for group, names in AMENITIES.items():
for name in names:
slug = _slugify(name)
amenity = Amenity(name=name, slug=slug, group=group)
db.session.add(amenity)
db.session.flush()
amenity_map[slug] = amenity
# ── Homepage Config ─────────────────────────────────────────────────────
db.session.add(
HomepageConfig(
hero_headline="Encontre o imóvel dos seus sonhos",
hero_subheadline="Mais de 200 imóveis disponíveis em toda a região",
hero_cta_label="Ver Imóveis",
hero_cta_url="/imoveis",
featured_properties_limit=6,
)
)
# ── Cities & Neighborhoods ────────────────────────────────────────────
city_map: dict[str, City] = {}
neighborhood_map: dict[str, Neighborhood] = {}
for loc_data in LOCATIONS:
city = City(
name=loc_data["name"],
slug=loc_data["slug"],
state=loc_data["state"],
)
db.session.add(city)
db.session.flush()
city_map[city.slug] = city
for nbh_data in loc_data["neighborhoods"]:
nbh = Neighborhood(
name=nbh_data["name"],
slug=nbh_data["slug"],
city_id=city.id,
)
db.session.add(nbh)
db.session.flush()
neighborhood_map[f"{city.slug}/{nbh.slug}"] = nbh
# ── Properties ──────────────────────────────────────────────────────────
for data in SAMPLE_PROPERTIES:
amenity_slugs: list[str] = data.pop("amenity_slugs")
photos_data: list[dict] = data.pop("photos")
subtype_slug: str = data.pop("subtype_slug")
city_slug: str | None = data.pop("city_slug", None)
neighborhood_slug: str | None = data.pop("neighborhood_slug", None)
city_id = city_map[city_slug].id if city_slug else None
nbh_key = (
f"{city_slug}/{neighborhood_slug}"
if city_slug and neighborhood_slug
else None
)
neighborhood_id = neighborhood_map[nbh_key].id if nbh_key else None
prop = Property(
**data,
subtype_id=subtype_map[subtype_slug].id,
city_id=city_id,
neighborhood_id=neighborhood_id,
)
db.session.add(prop)
db.session.flush()
for photo_data in photos_data:
db.session.add(PropertyPhoto(property_id=prop.id, **photo_data))
for slug in amenity_slugs:
if slug in amenity_map:
prop.amenities.append(amenity_map[slug])
db.session.commit()
# ── Admin user ──────────────────────────────────────────────────────────
ADMIN_EMAIL = "admin@master.com"
ADMIN_PASSWORD = "Hn84pFUgatYX"
admin = ClientUser.query.filter_by(email=ADMIN_EMAIL).first()
if not admin:
admin = ClientUser(
name="Admin",
email=ADMIN_EMAIL,
password_hash=bcrypt.hashpw(
ADMIN_PASSWORD.encode(), bcrypt.gensalt()
).decode(),
role="admin",
)
db.session.add(admin)
else:
admin.password_hash = bcrypt.hashpw(
ADMIN_PASSWORD.encode(), bcrypt.gensalt()
).decode()
admin.role = "admin"
db.session.commit()
print(f"Admin: {ADMIN_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)
total_nbhs = sum(len(loc["neighborhoods"]) for loc in LOCATIONS)
print(
f"Seed concluído: {len(SAMPLE_PROPERTIES)} imóveis, "
f"{total_types} tipos, {total_amenities} amenidades, "
f"{total_cities} cidades, {total_nbhs} bairros."
)
# ── Agents ─────────────────────────────────────────────────────────────
db.session.query(Agent).delete()
db.session.commit()
for agent_data in SAMPLE_AGENTS:
db.session.add(Agent(**agent_data))
db.session.commit()
print(f"Corretores seed: {len(SAMPLE_AGENTS)} cadastrados.")
if __name__ == "__main__":
seed()

34
backend/tests/conftest.py Normal file
View file

@ -0,0 +1,34 @@
import pytest
from app import create_app
from app.extensions import db as _db
@pytest.fixture(scope="session")
def app():
"""Create application for testing."""
flask_app = create_app("testing")
flask_app.config["TESTING"] = True
with flask_app.app_context():
_db.create_all()
yield flask_app
_db.drop_all()
@pytest.fixture(scope="function")
def db(app):
"""Provide a clean database for each test."""
with app.app_context():
yield _db
_db.session.rollback()
# Clean all tables
for table in reversed(_db.metadata.sorted_tables):
_db.session.execute(table.delete())
_db.session.commit()
@pytest.fixture(scope="function")
def client(app, db):
"""Create test client."""
return app.test_client()

View file

@ -0,0 +1,46 @@
import pytest
from pydantic import ValidationError
from app.models.homepage import HomepageConfig
from app.schemas.homepage import HomepageConfigIn
def test_get_homepage_config_returns_200(client, db):
"""GET /api/v1/homepage-config returns 200 with required fields when config exists."""
config = HomepageConfig(
hero_headline="Teste Headline",
hero_subheadline="Subtítulo de teste",
hero_cta_label="Ver Imóveis",
hero_cta_url="/imoveis",
featured_properties_limit=6,
)
db.session.add(config)
db.session.commit()
response = client.get("/api/v1/homepage-config")
assert response.status_code == 200
data = response.get_json()
assert data["hero_headline"] == "Teste Headline"
assert data["hero_subheadline"] == "Subtítulo de teste"
assert data["hero_cta_label"] == "Ver Imóveis"
assert data["hero_cta_url"] == "/imoveis"
assert data["featured_properties_limit"] == 6
def test_get_homepage_config_returns_404_when_empty(client, db):
"""GET /api/v1/homepage-config returns 404 when no config record exists."""
response = client.get("/api/v1/homepage-config")
assert response.status_code == 404
data = response.get_json()
assert "error" in data
def test_homepage_config_in_rejects_empty_headline():
"""HomepageConfigIn raises ValidationError when hero_headline is empty."""
with pytest.raises(ValidationError) as exc_info:
HomepageConfigIn(hero_headline="")
errors = exc_info.value.errors()
assert any("hero_headline" in str(e) for e in errors)

View file

@ -0,0 +1,229 @@
import uuid
from app.models.property import Property, PropertyPhoto
def _make_property(slug: str, featured: bool = True, **kwargs) -> Property:
defaults = dict(
id=uuid.uuid4(),
title=f"Imóvel {slug}",
slug=slug,
address="Rua Teste, 1",
price="500000.00",
type="venda",
bedrooms=2,
bathrooms=1,
area_m2=80,
is_featured=featured,
is_active=True,
)
defaults.update(kwargs)
return Property(**defaults)
def test_get_properties_featured_returns_200_with_array(client, db):
"""GET /api/v1/properties?featured=true returns 200 with an array."""
prop = _make_property("imovel-featured-1")
db.session.add(prop)
db.session.flush()
photo = PropertyPhoto(
property_id=prop.id,
url="https://picsum.photos/seed/test/800/450",
alt_text="Foto de teste",
display_order=0,
)
db.session.add(photo)
db.session.commit()
response = client.get("/api/v1/properties?featured=true")
assert response.status_code == 200
data = response.get_json()
assert isinstance(data, list)
assert len(data) >= 1
def test_get_properties_response_contains_required_fields(client, db):
"""Response contains all required fields: id, title, slug, price, type, etc."""
prop = _make_property("imovel-fields-check")
db.session.add(prop)
db.session.flush()
photo = PropertyPhoto(
property_id=prop.id,
url="https://picsum.photos/seed/fields/800/450",
alt_text="Foto de campos",
display_order=0,
)
db.session.add(photo)
db.session.commit()
response = client.get("/api/v1/properties?featured=true")
assert response.status_code == 200
data = response.get_json()
assert len(data) >= 1
item = data[0]
required_fields = [
"id",
"title",
"slug",
"price",
"type",
"bedrooms",
"bathrooms",
"area_m2",
"photos",
]
for field in required_fields:
assert field in item, f"Missing field: {field}"
assert isinstance(item["photos"], list)
def test_get_properties_featured_empty_returns_empty_array(client, db):
"""GET /api/v1/properties?featured=true returns [] when no featured properties exist."""
# Add a non-featured active property
prop = _make_property("imovel-not-featured", featured=False)
db.session.add(prop)
db.session.commit()
response = client.get("/api/v1/properties?featured=true")
assert response.status_code == 200
data = response.get_json()
assert isinstance(data, list)
assert data == []
# ── Text search (q param) ─────────────────────────────────────────────────────
def test_q_matches_title(client, db):
"""?q=<word> returns properties whose title contains that word."""
p1 = _make_property("casa-praia", title="Casa na Praia", price="400000.00")
p2 = _make_property("apto-centro", title="Apartamento Centro", price="300000.00")
db.session.add_all([p1, p2])
db.session.commit()
response = client.get("/api/v1/properties?q=praia")
assert response.status_code == 200
data = response.get_json()
ids = [item["id"] for item in data["items"]]
assert str(p1.id) in ids
assert str(p2.id) not in ids
def test_q_is_case_insensitive(client, db):
"""?q search is case-insensitive."""
p = _make_property("casa-jardins", title="Casa nos Jardins", price="600000.00")
db.session.add(p)
db.session.commit()
for term in ("jardins", "JARDINS", "Jardins"):
response = client.get(f"/api/v1/properties?q={term}")
assert response.status_code == 200
ids = [item["id"] for item in response.get_json()["items"]]
assert str(p.id) in ids, f"Expected to find property with q={term!r}"
def test_q_matches_address(client, db):
"""?q search matches against the address field."""
p = _make_property("rua-flores", address="Rua das Flores, 42", title="Imóvel Especial", price="350000.00")
db.session.add(p)
db.session.commit()
response = client.get("/api/v1/properties?q=Flores")
assert response.status_code == 200
ids = [item["id"] for item in response.get_json()["items"]]
assert str(p.id) in ids
def test_q_no_match_returns_empty(client, db):
"""?q with a term that matches nothing returns total=0 and empty items."""
p = _make_property("imovel-comum", title="Imóvel Comum", price="200000.00")
db.session.add(p)
db.session.commit()
response = client.get("/api/v1/properties?q=xyznaomatch999")
assert response.status_code == 200
data = response.get_json()
assert data["total"] == 0
assert data["items"] == []
def test_q_truncated_to_200_chars(client, db):
"""?q longer than 200 chars is accepted without error (truncated server-side)."""
long_q = "a" * 300
response = client.get(f"/api/v1/properties?q={long_q}")
assert response.status_code == 200
# ── Sort param ────────────────────────────────────────────────────────────────
def _add_props_for_sort(db):
"""Helper: add three properties with different price/area for sort tests."""
props = [
_make_property("sort-cheap", title="Barato", price="100000.00", area_m2=50),
_make_property("sort-mid", title="Médio", price="300000.00", area_m2=80),
_make_property("sort-exp", title="Caro", price="600000.00", area_m2=120),
]
db.session.add_all(props)
db.session.commit()
return props
def test_sort_price_asc(client, db):
"""?sort=price_asc returns properties ordered from cheapest to most expensive."""
_add_props_for_sort(db)
response = client.get("/api/v1/properties?sort=price_asc")
assert response.status_code == 200
items = response.get_json()["items"]
prices = [float(item["price"]) for item in items]
assert prices == sorted(prices), "Items should be sorted price ascending"
def test_sort_price_desc(client, db):
"""?sort=price_desc returns properties from most to least expensive."""
_add_props_for_sort(db)
response = client.get("/api/v1/properties?sort=price_desc")
assert response.status_code == 200
items = response.get_json()["items"]
prices = [float(item["price"]) for item in items]
assert prices == sorted(prices, reverse=True), "Items should be sorted price descending"
def test_sort_area_desc(client, db):
"""?sort=area_desc returns properties from largest to smallest area."""
_add_props_for_sort(db)
response = client.get("/api/v1/properties?sort=area_desc")
assert response.status_code == 200
items = response.get_json()["items"]
areas = [item["area_m2"] for item in items]
assert areas == sorted(areas, reverse=True), "Items should be sorted area descending"
def test_sort_unknown_value_falls_back_gracefully(client, db):
"""?sort=<invalid> returns 200 (falls back to default sort)."""
p = _make_property("sort-fallback", price="200000.00")
db.session.add(p)
db.session.commit()
response = client.get("/api/v1/properties?sort=invalid_value")
assert response.status_code == 200
def test_paginated_response_shape(client, db):
"""Paginated listing endpoint returns items, total, page, per_page, pages."""
p = _make_property("paginated-shape", price="250000.00")
db.session.add(p)
db.session.commit()
response = client.get("/api/v1/properties?per_page=5")
assert response.status_code == 200
data = response.get_json()
for key in ("items", "total", "page", "per_page", "pages"):
assert key in data, f"Missing key in paginated response: {key}"
assert isinstance(data["items"], list)
assert data["total"] >= 1

686
backend/uv.lock generated Normal file
View file

@ -0,0 +1,686 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "alembic"
version = "1.18.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "bcrypt"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "click"
version = "8.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
name = "flask-cors"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/70/74/0fc0fa68d62f21daef41017dafab19ef4b36551521260987eb3a5394c7ba/flask_cors-6.0.2.tar.gz", hash = "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423", size = 13472, upload-time = "2025-12-12T20:31:42.861Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257, upload-time = "2025-12-12T20:31:41.3Z" },
]
[[package]]
name = "flask-migrate"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alembic" },
{ name = "flask" },
{ name = "flask-sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/47c7b3c93855ceffc2eabfa271782332942443321a07de193e4198f920cf/flask_migrate-4.1.0.tar.gz", hash = "sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d", size = 21965, upload-time = "2025-01-10T18:51:11.848Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/c4/3f329b23d769fe7628a5fc57ad36956f1fb7132cf8837be6da762b197327/Flask_Migrate-4.1.0-py3-none-any.whl", hash = "sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", size = 21237, upload-time = "2025-01-10T18:51:09.527Z" },
]
[[package]]
name = "flask-sqlalchemy"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload-time = "2023-09-11T21:42:36.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload-time = "2023-09-11T21:42:34.514Z" },
]
[[package]]
name = "greenlet"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" },
{ url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" },
{ url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" },
{ url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" },
{ url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" },
{ url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" },
{ url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" },
{ url = "https://files.pythonhosted.org/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" },
{ url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" },
{ url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" },
{ url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" },
{ url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" },
{ url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" },
{ url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" },
{ url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" },
{ url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" },
{ url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" },
{ url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" },
{ url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" },
{ url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" },
{ url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" },
{ url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" },
{ url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" },
{ url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" },
{ url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "mako"
version = "1.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "psycopg2-binary"
version = "2.9.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" },
{ url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" },
{ url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" },
{ url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" },
{ url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" },
{ url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" },
{ url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" },
{ url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" },
{ url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" },
{ url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
{ url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
{ url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
{ url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
{ url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
{ url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
{ url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
{ url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
{ url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
{ url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
{ url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
{ url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
{ url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
{ url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
{ url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
{ url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
]
[[package]]
name = "pydantic"
version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/84/6b/69fd5c7194b21ebde0f8637e2a4ddc766ada29d472bfa6a5ca533d79549a/pydantic-2.13.0.tar.gz", hash = "sha256:b89b575b6e670ebf6e7448c01b41b244f471edd276cd0b6fe02e7e7aca320070", size = 843468, upload-time = "2026-04-13T10:51:35.571Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/d7/c3a52c61f5b7be648e919005820fbac33028c6149994cd64453f49951c17/pydantic-2.13.0-py3-none-any.whl", hash = "sha256:ab0078b90da5f3e2fd2e71e3d9b457ddcb35d0350854fbda93b451e28d56baaf", size = 471872, upload-time = "2026-04-13T10:51:33.343Z" },
]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]]
name = "pydantic-core"
version = "2.46.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6f/0a/9414cddf82eda3976b14048cc0fa8f5b5d1aecb0b22e1dcd2dbfe0e139b1/pydantic_core-2.46.0.tar.gz", hash = "sha256:82d2498c96be47b47e903e1378d1d0f770097ec56ea953322f39936a7cf34977", size = 471441, upload-time = "2026-04-13T09:06:33.813Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/d2/206c72ad47071559142a35f71efc29eb16448a4a5ae9487230ab8e4e292b/pydantic_core-2.46.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:66ccedb02c934622612448489824955838a221b3a35875458970521ef17b2f9c", size = 2117060, upload-time = "2026-04-13T09:04:47.443Z" },
{ url = "https://files.pythonhosted.org/packages/17/2c/7a53b33f91c8b77e696b1a6aa3bed609bf9374bdc0f8dcda681bc7d922b8/pydantic_core-2.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a44f27f4d2788ef9876ec47a43739b118c5904d74f418f53398f6ced3bbcacf2", size = 1951802, upload-time = "2026-04-13T09:05:34.591Z" },
{ url = "https://files.pythonhosted.org/packages/fc/20/90e548c1f6d38800ef11c915881525770ce270d8e5e887563ff046a08674/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26a1032bcce6ca4b4670eb3f7d8195bd0a8b8f255f1307823e217ca3cfa7c27", size = 1976621, upload-time = "2026-04-13T09:04:03.909Z" },
{ url = "https://files.pythonhosted.org/packages/20/3c/9c5810ca70b60c623488cdd80f7e9ee1a0812df81e97098b64788719860f/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b8d1412f725060527e56675904b17a2d421dddcf861eecf7c75b9dda47921a4", size = 2056721, upload-time = "2026-04-13T09:04:40.992Z" },
{ url = "https://files.pythonhosted.org/packages/1a/a3/d6e5f4cdec84278431c75540f90838c9d0a4dfe9402a8f3902073660ff28/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc3d1569edd859cabaa476cabce9eecd05049a7966af7b4a33b541bfd4ca1104", size = 2239634, upload-time = "2026-04-13T09:03:52.478Z" },
{ url = "https://files.pythonhosted.org/packages/46/42/ef58aacf330d8de6e309d62469aa1f80e945eaf665929b4037ac1bfcebc1/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38108976f2d8afaa8f5067fd1390a8c9f5cc580175407cda636e76bc76e88054", size = 2315739, upload-time = "2026-04-13T09:05:04.971Z" },
{ url = "https://files.pythonhosted.org/packages/8b/86/c63b12fafa2d86a515bfd1840b39c23a49302f02b653161bf9c3a0566c50/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5a06d8ed01dad5575056b5187e5959b336793c6047920a3441ee5b03533836", size = 2098169, upload-time = "2026-04-13T09:07:27.151Z" },
{ url = "https://files.pythonhosted.org/packages/76/19/b5b33a2f6be4755b21a20434293c4364be255f4c1a108f125d101d4cc4ee/pydantic_core-2.46.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:04017ace142da9ce27cafd423a480872571b5c7e80382aec22f7d715ca8eb870", size = 2170830, upload-time = "2026-04-13T09:04:39.448Z" },
{ url = "https://files.pythonhosted.org/packages/99/ae/7559f99a29b7d440012ddb4da897359304988a881efaca912fd2f655652e/pydantic_core-2.46.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2629ad992ed1b1c012e6067f5ffafd3336fcb9b54569449fabb85621f1444ed3", size = 2203901, upload-time = "2026-04-13T09:04:01.048Z" },
{ url = "https://files.pythonhosted.org/packages/dd/0e/b0ef945a39aeb4ac58da316813e1106b7fbdfbf20ac141c1c27904355ac5/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3068b1e7bd986aebc88f6859f8353e72072538dcf92a7fb9cf511a0f61c5e729", size = 2191789, upload-time = "2026-04-13T09:06:39.915Z" },
{ url = "https://files.pythonhosted.org/packages/90/f4/830484e07188c1236b013995818888ab93bab8fd88aa9689b1d8fd22220d/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:1e366916ff69ff700aa9326601634e688581bc24c5b6b4f8738d809ec7d72611", size = 2344423, upload-time = "2026-04-13T09:05:12.252Z" },
{ url = "https://files.pythonhosted.org/packages/fd/ba/e455c18cbdc333177af754e740be4fe9d1de173d65bbe534daf88da02ac0/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:485a23e8f4618a1b8e23ac744180acde283fffe617f96923d25507d5cade62ec", size = 2384037, upload-time = "2026-04-13T09:06:24.503Z" },
{ url = "https://files.pythonhosted.org/packages/78/1f/b35d20d73144a41e78de0ae398e60fdd8bed91667daa1a5a92ab958551ba/pydantic_core-2.46.0-cp312-cp312-win32.whl", hash = "sha256:520940e1b702fe3b33525d0351777f25e9924f1818ca7956447dabacf2d339fd", size = 1967068, upload-time = "2026-04-13T09:05:23.374Z" },
{ url = "https://files.pythonhosted.org/packages/d1/84/4b6252e9606e8295647b848233cc4137ee0a04ebba8f0f9fb2977655b38c/pydantic_core-2.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:90d2048e0339fa365e5a66aefe760ddd3b3d0a45501e088bc5bc7f4ed9ff9571", size = 2071008, upload-time = "2026-04-13T09:05:21.392Z" },
{ url = "https://files.pythonhosted.org/packages/39/95/d08eb508d4d5560ccbd226ee5971e5ef9b749aba9b413c0c4ed6e406d4f6/pydantic_core-2.46.0-cp312-cp312-win_arm64.whl", hash = "sha256:a70247649b7dffe36648e8f34be5ce8c5fa0a27ff07b071ea780c20a738c05ce", size = 2036634, upload-time = "2026-04-13T09:05:48.299Z" },
{ url = "https://files.pythonhosted.org/packages/df/05/ab3b0742bad1d51822f1af0c4232208408902bdcfc47601f3b812e09e6c2/pydantic_core-2.46.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a05900c37264c070c683c650cbca8f83d7cbb549719e645fcd81a24592eac788", size = 2116814, upload-time = "2026-04-13T09:04:12.41Z" },
{ url = "https://files.pythonhosted.org/packages/98/08/30b43d9569d69094a0899a199711c43aa58fce6ce80f6a8f7693673eb995/pydantic_core-2.46.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de8e482fd4f1e3f36c50c6aac46d044462615d8f12cfafc6bebeaa0909eea22", size = 1951867, upload-time = "2026-04-13T09:04:02.364Z" },
{ url = "https://files.pythonhosted.org/packages/db/a0/bf9a1ba34537c2ed3872a48195291138fdec8fe26c4009776f00d63cf0c8/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c525ecf8a4cdf198327b65030a7d081867ad8e60acb01a7214fff95cf9832d47", size = 1977040, upload-time = "2026-04-13T09:06:16.088Z" },
{ url = "https://files.pythonhosted.org/packages/71/70/0ba03c20e1e118219fc18c5417b008b7e880f0e3fb38560ec4465984d471/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f14581aeb12e61542ce73b9bfef2bca5439d65d9ab3efe1a4d8e346b61838f9b", size = 2055284, upload-time = "2026-04-13T09:05:25.125Z" },
{ url = "https://files.pythonhosted.org/packages/58/cf/1e320acefbde7fb7158a9e5def55e0adf9a4634636098ce28dc6b978e0d3/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c108067f2f7e190d0dbd81247d789ec41f9ea50ccd9265a3a46710796ac60530", size = 2238896, upload-time = "2026-04-13T09:05:01.345Z" },
{ url = "https://files.pythonhosted.org/packages/df/f5/ea8ba209756abe9eba891bb0ef3772b4c59a894eb9ad86cd5bd0dd4e3e52/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ac10967e9a7bb1b96697374513f9a1a90a59e2fb41566b5e00ee45392beac59", size = 2314353, upload-time = "2026-04-13T09:06:07.942Z" },
{ url = "https://files.pythonhosted.org/packages/e8/f8/5885350203b72e96438eee7f94de0d8f0442f4627237ca8ef75de34db1cd/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7897078fe8a13b73623c0955dfb2b3d2c9acb7177aac25144758c9e5a5265aaa", size = 2098522, upload-time = "2026-04-13T09:04:23.239Z" },
{ url = "https://files.pythonhosted.org/packages/bf/88/5930b0e828e371db5a556dd3189565417ddc3d8316bb001058168aadcf5f/pydantic_core-2.46.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:e69ce405510a419a082a78faed65bb4249cfb51232293cc675645c12f7379bf7", size = 2168757, upload-time = "2026-04-13T09:07:12.46Z" },
{ url = "https://files.pythonhosted.org/packages/da/75/63d563d3035a0548e721c38b5b69fd5626fdd51da0f09ff4467503915b82/pydantic_core-2.46.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd28d13eea0d8cf351dc1fe274b5070cc8e1cca2644381dee5f99de629e77cf3", size = 2202518, upload-time = "2026-04-13T09:05:44.418Z" },
{ url = "https://files.pythonhosted.org/packages/a7/53/1958eacbfddc41aadf5ae86dd85041bf054b675f34a2fa76385935f96070/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ee1547a6b8243e73dd10f585555e5a263395e55ce6dea618a078570a1e889aef", size = 2190148, upload-time = "2026-04-13T09:06:56.151Z" },
{ url = "https://files.pythonhosted.org/packages/c7/17/098cc6d3595e4623186f2bc6604a6195eb182e126702a90517236391e9ce/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c3dc68dcf62db22a18ddfc3ad4960038f72b75908edc48ae014d7ac8b391d57a", size = 2342925, upload-time = "2026-04-13T09:04:17.286Z" },
{ url = "https://files.pythonhosted.org/packages/71/a7/abdb924620b1ac535c690b36ad5b8871f376104090f8842c08625cecf1d3/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:004a2081c881abfcc6854a4623da6a09090a0d7c1398a6ae7133ca1256cee70b", size = 2383167, upload-time = "2026-04-13T09:04:52.643Z" },
{ url = "https://files.pythonhosted.org/packages/d7/c9/2ddd10f50e4b7350d2574629a0f53d8d4eb6573f9c19a6b43e6b1487a31d/pydantic_core-2.46.0-cp313-cp313-win32.whl", hash = "sha256:59d24ec8d5eaabad93097525a69d0f00f2667cb353eb6cda578b1cfff203ceef", size = 1965660, upload-time = "2026-04-13T09:06:05.877Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e7/1efc38ed6f2680c032bcefa0e3ebd496a8c77e92dfdb86b07d0f2fc632b1/pydantic_core-2.46.0-cp313-cp313-win_amd64.whl", hash = "sha256:71186dad5ac325c64d68fe0e654e15fd79802e7cc42bc6f0ff822d5ad8b1ab25", size = 2069563, upload-time = "2026-04-13T09:07:14.738Z" },
{ url = "https://files.pythonhosted.org/packages/c3/1e/a325b4989e742bf7e72ed35fa124bc611fd76539c9f8cd2a9a7854473533/pydantic_core-2.46.0-cp313-cp313-win_arm64.whl", hash = "sha256:8e4503f3213f723842c9a3b53955c88a9cfbd0b288cbd1c1ae933aebeec4a1b4", size = 2034966, upload-time = "2026-04-13T09:04:21.629Z" },
{ url = "https://files.pythonhosted.org/packages/36/3b/914891d384cdbf9a6f464eb13713baa22ea1e453d4da80fb7da522079370/pydantic_core-2.46.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4fc801c290342350ffc82d77872054a934b2e24163727263362170c1db5416ca", size = 2113349, upload-time = "2026-04-13T09:04:59.407Z" },
{ url = "https://files.pythonhosted.org/packages/35/95/3a0c6f65e231709fb3463e32943c69d10285cb50203a2130a4732053a06d/pydantic_core-2.46.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0a36f2cc88170cc177930afcc633a8c15907ea68b59ac16bd180c2999d714940", size = 1949170, upload-time = "2026-04-13T09:06:09.935Z" },
{ url = "https://files.pythonhosted.org/packages/d1/63/d845c36a608469fe7bee226edeff0984c33dbfe7aecd755b0e7ab5a275c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a3912e0c568a1f99d4d6d3e41def40179d61424c0ca1c8c87c4877d7f6fd7fb", size = 1977914, upload-time = "2026-04-13T09:04:56.16Z" },
{ url = "https://files.pythonhosted.org/packages/08/6f/f2e7a7f85931fb31671f5378d1c7fc70606e4b36d59b1b48e1bd1ef5d916/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3534c3415ed1a19ab23096b628916a827f7858ec8db49ad5d7d1e44dc13c0d7b", size = 2050538, upload-time = "2026-04-13T09:05:06.789Z" },
{ url = "https://files.pythonhosted.org/packages/8c/97/f4aa7181dd9a16dd9059a99fc48fdab0c2aab68307283a5c04cf56de68c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21067396fc285609323a4db2f63a87570044abe0acddfcca8b135fc7948e3db7", size = 2236294, upload-time = "2026-04-13T09:07:03.2Z" },
{ url = "https://files.pythonhosted.org/packages/24/c1/6a5042fc32765c87101b500f394702890af04239c318b6002cfd627b710d/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2afd85b7be186e2fe7cdbb09a3d964bcc2042f65bbcc64ad800b3c7915032655", size = 2312954, upload-time = "2026-04-13T09:06:11.919Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e4/566101a561492ce8454f0844ca29c3b675a6b3a7b3ff577db85ed05c8c50/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67e2c2e171b78db8154da602de72ffdc473c6ee51de8a9d80c0f1cd4051abfc7", size = 2102533, upload-time = "2026-04-13T09:06:58.664Z" },
{ url = "https://files.pythonhosted.org/packages/3e/ac/adc11ee1646a5c4dd9abb09a00e7909e6dc25beddc0b1310ca734bb9b48e/pydantic_core-2.46.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c16ae1f3170267b1a37e16dba5c297bdf60c8b5657b147909ca8774ce7366644", size = 2169447, upload-time = "2026-04-13T09:04:11.143Z" },
{ url = "https://files.pythonhosted.org/packages/26/73/408e686b45b82d28ac19e8229e07282254dbee6a5d24c5c7cf3cf3716613/pydantic_core-2.46.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:133b69e1c1ba34d3702eed73f19f7f966928f9aa16663b55c2ebce0893cca42e", size = 2200672, upload-time = "2026-04-13T09:03:54.056Z" },
{ url = "https://files.pythonhosted.org/packages/0a/3b/807d5b035ec891b57b9079ce881f48263936c37bd0d154a056e7fd152afb/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:15ed8e5bde505133d96b41702f31f06829c46b05488211a5b1c7877e11de5eb5", size = 2188293, upload-time = "2026-04-13T09:07:07.614Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ed/719b307516285099d1196c52769fdbe676fd677da007b9c349ae70b7226d/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:8cfc29a1c66a7f0fcb36262e92f353dd0b9c4061d558fceb022e698a801cb8ae", size = 2335023, upload-time = "2026-04-13T09:04:05.176Z" },
{ url = "https://files.pythonhosted.org/packages/8d/90/8718e4ae98c4e8a7325afdc079be82be1e131d7a47cb6c098844a9531ffe/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e1155708540f13845bf68d5ac511a55c76cfe2e057ed12b4bf3adac1581fc5c2", size = 2377155, upload-time = "2026-04-13T09:06:18.081Z" },
{ url = "https://files.pythonhosted.org/packages/dd/dc/7172789283b963f81da2fc92b186e22de55687019079f71c4d570822502b/pydantic_core-2.46.0-cp314-cp314-win32.whl", hash = "sha256:de5635a48df6b2eef161d10ea1bc2626153197333662ba4cd700ee7ec1aba7f5", size = 1963078, upload-time = "2026-04-13T09:05:30.615Z" },
{ url = "https://files.pythonhosted.org/packages/e0/69/03a7ea4b6264def3a44eabf577528bcec2f49468c5698b2044dea54dc07e/pydantic_core-2.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:f07a5af60c5e7cf53dd1ff734228bd72d0dc9938e64a75b5bb308ca350d9681e", size = 2068439, upload-time = "2026-04-13T09:04:57.729Z" },
{ url = "https://files.pythonhosted.org/packages/f5/eb/1c3afcfdee2ab6634b802ab0a0f1966df4c8b630028ec56a1cb0a710dc58/pydantic_core-2.46.0-cp314-cp314-win_arm64.whl", hash = "sha256:e7a77eca3c7d5108ff509db20aae6f80d47c7ed7516d8b96c387aacc42f3ce0f", size = 2026470, upload-time = "2026-04-13T09:05:08.654Z" },
{ url = "https://files.pythonhosted.org/packages/5c/30/1177dde61b200785c4739665e3aa03a9d4b2c25d2d0408b07d585e633965/pydantic_core-2.46.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5e7cdd4398bee1aaeafe049ac366b0f887451d9ae418fd8785219c13fea2f928", size = 2107447, upload-time = "2026-04-13T09:05:46.314Z" },
{ url = "https://files.pythonhosted.org/packages/b1/60/4e0f61f99bdabbbc309d364a2791e1ba31e778a4935bc43391a7bdec0744/pydantic_core-2.46.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5c2c92d82808e27cef3f7ab3ed63d657d0c755e0dbe5b8a58342e37bdf09bd2e", size = 1926927, upload-time = "2026-04-13T09:06:20.371Z" },
{ url = "https://files.pythonhosted.org/packages/1d/d0/67f89a8269152c1d6eaa81f04e75a507372ebd8ca7382855a065222caa80/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bab80af91cd7014b45d1089303b5f844a9d91d7da60eabf3d5f9694b32a6655", size = 1966613, upload-time = "2026-04-13T09:07:05.389Z" },
{ url = "https://files.pythonhosted.org/packages/cd/07/8dfdc3edc78f29a80fb31f366c50203ec904cff6a4c923599bf50ac0d0ff/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e49ffdb714bc990f00b39d1ad1d683033875b5af15582f60c1f34ad3eeccfaa", size = 2032902, upload-time = "2026-04-13T09:06:42.47Z" },
{ url = "https://files.pythonhosted.org/packages/b0/2a/111c5e8fe24f99c46bcad7d3a82a8f6dbc738066e2c72c04c71f827d8c78/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca877240e8dbdeef3a66f751dc41e5a74893767d510c22a22fc5c0199844f0ce", size = 2244456, upload-time = "2026-04-13T09:05:36.484Z" },
{ url = "https://files.pythonhosted.org/packages/6b/7c/cfc5d11c15a63ece26e148572c77cfbb2c7f08d315a7b63ef0fe0711d753/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87e6843f89ecd2f596d7294e33196c61343186255b9880c4f1b725fde8b0e20d", size = 2294535, upload-time = "2026-04-13T09:06:01.689Z" },
{ url = "https://files.pythonhosted.org/packages/c4/2c/f0d744e3dab7bd026a3f4670a97a295157cff923a2666d30a15a70a7e3d0/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e20bc5add1dd9bc3b9a3600d40632e679376569098345500799a6ad7c5d46c72", size = 2104621, upload-time = "2026-04-13T09:04:34.388Z" },
{ url = "https://files.pythonhosted.org/packages/a7/64/e7cc4698dc024264d214b51d5a47a2404221b12060dd537d76f831b2120a/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:ee6ff79a5f0289d64a9d6696a3ce1f98f925b803dd538335a118231e26d6d827", size = 2130718, upload-time = "2026-04-13T09:04:26.23Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a8/224e655fec21f7d4441438ad2ecaccb33b5a3876ce7bb2098c74a49efc14/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:52d35cfb58c26323101c7065508d7bb69bb56338cda9ea47a7b32be581af055d", size = 2180738, upload-time = "2026-04-13T09:05:50.253Z" },
{ url = "https://files.pythonhosted.org/packages/32/7b/b3025618ed4c4e4cbaa9882731c19625db6669896b621760ea95bc1125ef/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d14cc5a6f260fa78e124061eebc5769af6534fc837e9a62a47f09a2c341fa4ea", size = 2171222, upload-time = "2026-04-13T09:07:29.929Z" },
{ url = "https://files.pythonhosted.org/packages/7b/e3/68170aa1d891920af09c1f2f34df61dc5ff3a746400027155523e3400e89/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:4f7ff859d663b6635f6307a10803d07f0d09487e16c3d36b1744af51dbf948b2", size = 2320040, upload-time = "2026-04-13T09:06:35.732Z" },
{ url = "https://files.pythonhosted.org/packages/67/1b/5e65807001b84972476300c1f49aea2b4971b7e9fffb5c2654877dadd274/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8ef749be6ed0d69dba31902aaa8255a9bb269ae50c93888c4df242d8bb7acd9e", size = 2377062, upload-time = "2026-04-13T09:07:39.945Z" },
{ url = "https://files.pythonhosted.org/packages/75/03/48caa9dd5f28f7662bd52bff454d9a451f6b7e5e4af95e289e5e170749c9/pydantic_core-2.46.0-cp314-cp314t-win32.whl", hash = "sha256:d93ca72870133f86360e4bb0c78cd4e6ba2a0f9f3738a6486909ffc031463b32", size = 1951028, upload-time = "2026-04-13T09:04:20.224Z" },
{ url = "https://files.pythonhosted.org/packages/87/ed/e97ff55fe28c0e6e3cba641d622b15e071370b70e5f07c496b07b65db7c9/pydantic_core-2.46.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ebb2668afd657e2127cb40f2ceb627dd78e74e9dfde14d9bf6cdd532a29ff59", size = 2048519, upload-time = "2026-04-13T09:05:10.464Z" },
{ url = "https://files.pythonhosted.org/packages/b6/51/e0db8267a287994546925f252e329eeae4121b1e77e76353418da5a3adf0/pydantic_core-2.46.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4864f5bbb7993845baf9209bae1669a8a76769296a018cb569ebda9dcb4241f5", size = 2026791, upload-time = "2026-04-13T09:04:37.724Z" },
{ url = "https://files.pythonhosted.org/packages/74/0c/106ed5cc50393d90523f09adcc50d05e42e748eb107dc06aea971137f02d/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:bc0e2fefe384152d7da85b5c2fe8ce2bf24752f68a58e3f3ea42e28a29dfdeb2", size = 2104968, upload-time = "2026-04-13T09:06:26.967Z" },
{ url = "https://files.pythonhosted.org/packages/f5/71/b494cef3165e3413ee9bbbb5a9eedc9af0ea7b88d8638beef6c2061b110e/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a2ab0e785548be1b4362a62c4004f9217598b7ee465f1f420fc2123e2a5b5b02", size = 1940442, upload-time = "2026-04-13T09:06:29.332Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3e/a4d578c8216c443e26a1124f8c1e07c0654264ce5651143d3883d85ff140/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16d45aecb18b8cba1c68eeb17c2bb2d38627ceed04c5b30b882fc9134e01f187", size = 1999672, upload-time = "2026-04-13T09:04:42.798Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c1/9114560468685525a21770138382fd0cb849aaf351ff2c7b97f760d121e0/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5078f6c377b002428e984259ac327ef8902aacae6c14b7de740dd4869a491501", size = 2154533, upload-time = "2026-04-13T09:04:50.868Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyjwt"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-flask"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "pytest" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/23/32b36d2f769805c0f3069ca8d9eeee77b27fcf86d41d40c6061ddce51c7d/pytest-flask-1.3.0.tar.gz", hash = "sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e", size = 35816, upload-time = "2023-10-23T14:53:20.696Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/03/7a917fda3d0e96b4e80ab1f83a6628ec4ee4a882523b49417d3891bacc9e/pytest_flask-1.3.0-py3-none-any.whl", hash = "sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253", size = 13105, upload-time = "2023-10-23T14:53:18.959Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "saas-imobiliaria-backend"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "bcrypt" },
{ name = "flask" },
{ name = "flask-cors" },
{ name = "flask-migrate" },
{ name = "flask-sqlalchemy" },
{ name = "psycopg2-binary" },
{ name = "pydantic", extra = ["email"] },
{ name = "pyjwt" },
{ name = "python-dotenv" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-flask" },
]
[package.metadata]
requires-dist = [
{ name = "bcrypt", specifier = ">=4.2" },
{ name = "flask", specifier = ">=3.0" },
{ name = "flask-cors", specifier = ">=4.0" },
{ name = "flask-migrate", specifier = ">=4.0" },
{ name = "flask-sqlalchemy", specifier = ">=3.1" },
{ name = "psycopg2-binary", specifier = ">=2.9" },
{ name = "pydantic", specifier = ">=2.7" },
{ name = "pydantic", extras = ["email"] },
{ name = "pyjwt", specifier = ">=2.9" },
{ name = "python-dotenv", specifier = ">=1.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-flask", specifier = ">=1.3" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.49"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" },
{ url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" },
{ url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" },
{ url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" },
{ url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" },
{ url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" },
{ url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" },
{ url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" },
{ url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" },
{ url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" },
{ url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" },
{ url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" },
{ url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" },
{ url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" },
{ url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" },
{ url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" },
{ url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" },
{ url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" },
{ url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" },
{ url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" },
{ url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" },
{ url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" },
{ url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" },
{ url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" },
{ url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" },
{ url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" },
{ url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" },
{ url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
]