feat: add full project - backend, frontend, docker, specs and configs
This commit is contained in:
parent
b77c7d5a01
commit
e6cb06255b
24489 changed files with 61341 additions and 36 deletions
5
backend/.env.example
Normal file
5
backend/.env.example
Normal 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
25
backend/Dockerfile
Normal 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
81
backend/app/__init__.py
Normal 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
42
backend/app/config.py
Normal 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,
|
||||
}
|
||||
7
backend/app/extensions.py
Normal file
7
backend/app/extensions.py
Normal 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()
|
||||
5
backend/app/models/__init__.py
Normal file
5
backend/app/models/__init__.py
Normal 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
|
||||
25
backend/app/models/agent.py
Normal file
25
backend/app/models/agent.py
Normal 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}>"
|
||||
30
backend/app/models/boleto.py
Normal file
30
backend/app/models/boleto.py
Normal 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")
|
||||
67
backend/app/models/catalog.py
Normal file
67
backend/app/models/catalog.py
Normal 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}>"
|
||||
22
backend/app/models/homepage.py
Normal file
22
backend/app/models/homepage.py
Normal 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}>"
|
||||
18
backend/app/models/imobiliaria.py
Normal file
18
backend/app/models/imobiliaria.py
Normal 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}>"
|
||||
25
backend/app/models/lead.py
Normal file
25
backend/app/models/lead.py
Normal 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}>"
|
||||
46
backend/app/models/location.py
Normal file
46
backend/app/models/location.py
Normal 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}>"
|
||||
21
backend/app/models/page_view.py
Normal file
21
backend/app/models/page_view.py
Normal 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}>"
|
||||
91
backend/app/models/property.py
Normal file
91
backend/app/models/property.py
Normal 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}>"
|
||||
33
backend/app/models/saved_property.py
Normal file
33
backend/app/models/saved_property.py
Normal 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")
|
||||
38
backend/app/models/user.py
Normal file
38
backend/app/models/user.py
Normal 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)
|
||||
30
backend/app/models/visit_request.py
Normal file
30
backend/app/models/visit_request.py
Normal 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")
|
||||
0
backend/app/routes/__init__.py
Normal file
0
backend/app/routes/__init__.py
Normal file
1007
backend/app/routes/admin.py
Normal file
1007
backend/app/routes/admin.py
Normal file
File diff suppressed because it is too large
Load diff
92
backend/app/routes/agents.py
Normal file
92
backend/app/routes/agents.py
Normal 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"})
|
||||
173
backend/app/routes/analytics.py
Normal file
173
backend/app/routes/analytics.py
Normal 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
100
backend/app/routes/auth.py
Normal 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
|
||||
63
backend/app/routes/catalog.py
Normal file
63
backend/app/routes/catalog.py
Normal 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]
|
||||
)
|
||||
109
backend/app/routes/client_area.py
Normal file
109
backend/app/routes/client_area.py
Normal 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,
|
||||
)
|
||||
11
backend/app/routes/config.py
Normal file
11
backend/app/routes/config.py
Normal 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})
|
||||
14
backend/app/routes/homepage.py
Normal file
14
backend/app/routes/homepage.py
Normal 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())
|
||||
155
backend/app/routes/locations.py
Normal file
155
backend/app/routes/locations.py
Normal 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
|
||||
262
backend/app/routes/properties.py
Normal file
262
backend/app/routes/properties.py
Normal 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,
|
||||
)
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
56
backend/app/schemas/agent.py
Normal file
56
backend/app/schemas/agent.py
Normal 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()
|
||||
68
backend/app/schemas/auth.py
Normal file
68
backend/app/schemas/auth.py
Normal 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
|
||||
58
backend/app/schemas/catalog.py
Normal file
58
backend/app/schemas/catalog.py
Normal 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
|
||||
95
backend/app/schemas/client_area.py
Normal file
95
backend/app/schemas/client_area.py
Normal 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
|
||||
37
backend/app/schemas/homepage.py
Normal file
37
backend/app/schemas/homepage.py
Normal 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
|
||||
64
backend/app/schemas/lead.py
Normal file
64
backend/app/schemas/lead.py
Normal 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
|
||||
57
backend/app/schemas/property.py
Normal file
57
backend/app/schemas/property.py
Normal 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
|
||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
67
backend/app/utils/auth.py
Normal file
67
backend/app/utils/auth.py
Normal 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
26
backend/entrypoint.sh
Normal 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
|
||||
1
backend/migrations/README
Normal file
1
backend/migrations/README
Normal file
|
|
@ -0,0 +1 @@
|
|||
Single-database configuration for Flask.
|
||||
50
backend/migrations/alembic.ini
Normal file
50
backend/migrations/alembic.ini
Normal 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
113
backend/migrations/env.py
Normal 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()
|
||||
24
backend/migrations/script.py.mako
Normal file
24
backend/migrations/script.py.mako
Normal 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"}
|
||||
|
|
@ -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 ###
|
||||
42
backend/migrations/versions/a1b2c3d4e5f6_add_client_users.py
Normal file
42
backend/migrations/versions/a1b2c3d4e5f6_add_client_users.py
Normal 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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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")
|
||||
23
backend/migrations/versions/d0e1f2a3b4c5_merge_heads.py
Normal file
23
backend/migrations/versions/d0e1f2a3b4c5_merge_heads.py
Normal 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
|
||||
|
|
@ -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")
|
||||
41
backend/migrations/versions/e1f2a3b4c5d6_add_agents_table.py
Normal file
41
backend/migrations/versions/e1f2a3b4c5d6_add_agents_table.py
Normal 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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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 ###
|
||||
28
backend/migrations/versions/f1a2b3c4d5e6_add_iptu_anual.py
Normal file
28
backend/migrations/versions/f1a2b3c4d5e6_add_iptu_anual.py
Normal 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")
|
||||
|
|
@ -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
29
backend/pyproject.toml
Normal 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
6
backend/run.py
Normal 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)
|
||||
30
backend/seeds/data/amenidades_pendentes.csv
Normal file
30
backend/seeds/data/amenidades_pendentes.csv
Normal 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,
|
||||
|
12064
backend/seeds/data/fotos.csv
Normal file
12064
backend/seeds/data/fotos.csv
Normal file
File diff suppressed because it is too large
Load diff
7122
backend/seeds/data/imoveis.csv
Normal file
7122
backend/seeds/data/imoveis.csv
Normal file
File diff suppressed because it is too large
Load diff
385
backend/seeds/import_from_csv.py
Normal file
385
backend/seeds/import_from_csv.py
Normal 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` já 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
798
backend/seeds/seed.py
Normal 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
34
backend/tests/conftest.py
Normal 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()
|
||||
46
backend/tests/test_homepage.py
Normal file
46
backend/tests/test_homepage.py
Normal 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)
|
||||
229
backend/tests/test_properties.py
Normal file
229
backend/tests/test_properties.py
Normal 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
686
backend/uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue