Toolbox da Bode.io para projetos em Django — JWT authentication, utility models, helpers and services.
- JWT authentication via
djangorestframework-simplejwt - DRF endpoints: login, logout, register, password change, password reset request/confirm, e-mail confirmation and Google social login
- OTP strategy for email confirmation and password reset (configurable — magic link is the default)
- Passwordless login via email OTP or magic link (opt-in)
- Abstract base models:
BaseModel(timestamps) andLogicDeletable(soft deletion) - Built-in models:
UserAuth,Pais,LoginRecord,ConsultaCEP,OptimizedImageWithTinyPNG - CEP lookup service with ViaCEP / AwesomeAPI fallback and database caching
- Login tracking via Django signals
- Utility functions: file cleaners, date/string/number formatters, workday calculator, email obfuscation, MX-validating form field, PBKDF2 hashing, pagination fix, MySQL
ROUND - Template tags:
grana(R$ formatting),multiply,roi,url_replace - Sentry metrics helpers (optional)
- Management commands: country import, TinyPNG image compression
- Standardized success and error responses across all endpoints
pip install git+https://github.com/bodedev/bodepontoio.git@mainOptional extras:
pip install "django-bodepontoio[auth]" # JWT auth endpoints (DRF + simplejwt)
pip install "django-bodepontoio[tinify]" # TinyPNG image compression
pip install "django-bodepontoio[sentry]" # Sentry metrics helpersNote: The
authextra is required if you use any of the authentication views, serializers, renderers or pagination classes.
# settings.py
INSTALLED_APPS = [
...
"rest_framework",
"rest_framework_simplejwt",
"rest_framework_simplejwt.token_blacklist", # required for LogoutView
"bodepontoio",
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
"EXCEPTION_HANDLER": "bodepontoio.exceptions.exception_handler",
"DEFAULT_RENDERER_CLASSES": ["bodepontoio.renderers.SuccessJSONRenderer"],
}
SIMPLE_JWT = {
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
}
# Optional bodepontoio overrides
BODEPONTOIO = {
"FRONTEND_URL": "https://app.example.com", # default: "http://localhost:3000"
"PASSWORD_RESET_URL_PATH": "/reset/{uid}/{token}/", # default: "/reset-password/{uid}/{token}/"
"LOGIN_MAGIC_LINK_URL_PATH": "/auth/magic/{uid}/{token}/", # default: "/login/magic/{uid}/{token}/"
"GOOGLE_CLIENT_ID": "your-client-id.apps.googleusercontent.com", # required for Google login
# OTP strategies (default: "magic_link" — backward compatible)
"EMAIL_CONFIRM_STRATEGY": "otp", # "magic_link" or "otp"
"PASSWORD_RESET_STRATEGY": "otp", # "magic_link" or "otp"
# OTP options
"OTP_LENGTH": 6, # digits in the generated code
"OTP_EXPIRY_SECONDS": 900, # 15 minutes
"OTP_MAX_ATTEMPTS": 5, # wrong attempts before the code is burned
# Login strategy (default: "password")
"LOGIN_STRATEGY": "otp", # "password", "otp" or "magic_link"
"LOGIN_AUTO_SIGNUP": True, # default: False — see Passwordless Login
}# urls.py
path("api/auth/", include("bodepontoio.urls", namespace="bodepontoio")),python manage.py migrate| Method | URL | Permission | Description |
|---|---|---|---|
| POST | login/ |
Public | Obtain tokens (password), request OTP code, or send magic link (strategy-dependent) |
| POST | login/otp/confirm/ |
Public | Exchange OTP code for tokens ¹ |
| POST | login/magic/confirm/ |
Public | Exchange magic link uid + token for tokens ³ |
| POST | login/resend/ |
Public | Re-send OTP code or magic link ⁴ |
| POST | token/refresh/ |
Public | Refresh access token |
| POST | logout/ |
Authenticated | Blacklist refresh token |
| POST | register/ |
Public | Create account, sends confirmation email |
| POST | password/change/ |
Authenticated | Change password |
| POST | password/reset/ |
Public | Request reset link or code |
| POST | password/reset/confirm/ |
Public | Confirm reset with uid + token (magic link) |
| POST | password/reset/confirm/otp/ |
Public | Confirm reset with email + OTP code ² |
| GET | email/confirm/<uid>/<token>/ |
Public | Confirm email address (magic link) |
| POST | email/confirm/otp/ |
Public | Confirm email address with OTP code ² |
| POST | email/confirm/resend/ |
Public | Re-send confirmation email or code |
| POST | social/google/ |
Public | Login or register via Google ID token |
¹ Returns 404 unless LOGIN_STRATEGY = "otp". login/ always exists but its behaviour changes with the strategy.
² Returns 404 unless the matching strategy is set to "otp".
³ Returns 404 unless LOGIN_STRATEGY = "magic_link".
⁴ Returns 404 unless LOGIN_STRATEGY is "otp" or "magic_link".
POST login/ accepts:
| Field | Required | Notes |
|---|---|---|
login |
Yes | Username or email address |
password |
Yes |
POST register/ accepts:
| Field | Required | Notes |
|---|---|---|
username |
Yes | Must be unique |
email |
Yes | Must be unique |
password |
Yes | Minimum 8 characters |
first_name |
No | |
last_name |
No |
A confirmation email is sent automatically. The user cannot log in until the email is confirmed.
POST social/google/ accepts {"id_token": "<google-oauth2-id-token>"}.
Pass the ID token issued by Google's OAuth 2.0 flow (e.g. from Google Sign-In for Web or a mobile SDK). The endpoint verifies the token against the configured client ID, then:
- Creates a new user (with email, first_name, last_name from the token) if one doesn't exist yet
- Marks the email as verified automatically
- Returns the same
access+refreshtokens as regular login
Requires BODEPONTOIO["GOOGLE_CLIENT_ID"] to be set.
When a user registers, a confirmation email is sent automatically. Login is blocked until the email address is confirmed.
- User registers → confirmation email sent
- User clicks the link in the email →
GET email/confirm/<uid>/<token>/→ account activated - User can now log in
POST email/confirm/resend/ accepts {"email": "..."}. It silently does nothing if the address is unknown or already confirmed (anti-enumeration).
Django template override (no config needed): create a file at the same path inside any directory listed in TEMPLATES[0]['DIRS']:
your_project/
templates/
bodepontoio/
email_confirmation_email.html
| Variable | Description |
|---|---|
user |
The User instance |
confirm_url |
Full confirmation URL |
Email confirmation and password reset both support two delivery strategies, configured independently:
BODEPONTOIO = {
"EMAIL_CONFIRM_STRATEGY": "otp", # or "magic_link" (default)
"PASSWORD_RESET_STRATEGY": "otp", # or "magic_link" (default)
}The user receives an email with a signed URL. They click it or paste it in a browser. No new endpoints required — backward compatible.
The user receives a short numeric code (default: 6 digits, 15 minutes expiry). They submit it via a POST endpoint:
- Email confirmation:
POST email/confirm/otp/with{"email": "...", "code": "..."} - Password reset:
POST password/reset/confirm/otp/with{"email": "...", "code": "...", "new_password": "..."}
The magic-link endpoints remain registered but return 404 when the OTP strategy is active, and vice-versa.
| Setting | Default | Description |
|---|---|---|
OTP_LENGTH |
6 |
Number of digits in the generated code |
OTP_EXPIRY_SECONDS |
900 |
Seconds until the code expires (15 minutes) |
OTP_MAX_ATTEMPTS |
5 |
Wrong attempts before the code is burned |
| Template | Strategy | Flow |
|---|---|---|
bodepontoio/email_confirmation_email.html |
magic_link |
Email confirmation |
bodepontoio/email_confirmation_otp.html |
otp |
Email confirmation |
bodepontoio/password_reset_email.html |
magic_link |
Password reset |
bodepontoio/password_reset_otp.html |
otp |
Password reset |
OTP templates receive {{ otp_code }} and {{ expiry_minutes }} instead of a URL.
Users can log in with just their email address — no password required. Two delivery strategies are available:
BODEPONTOIO = {
"LOGIN_STRATEGY": "otp", # "password" (default), "otp", or "magic_link"
}login/ is always the entry point — its behaviour depends on the active strategy:
| Strategy | POST login/ accepts |
Returns |
|---|---|---|
"password" (default) |
{"login": "...", "password": "..."} |
{access, refresh} tokens |
"otp" |
{"email": "..."} |
200 message, sends OTP code by email |
"magic_link" |
{"email": "..."} |
200 message, sends a signed login link by email |
The user then completes login with:
POST login/otp/confirm/with{"email": "...", "code": "..."}→ returns{access, refresh}(OTP strategy)POST login/magic/confirm/with{"uid": "...", "token": "..."}→ returns{access, refresh}(magic link strategy)
Each confirm endpoint returns 404 when its matching strategy is not active.
When LOGIN_STRATEGY is "otp" or "magic_link" and LOGIN_AUTO_SIGNUP is True, POST login/ for an unknown email creates the account on the fly (with an unusable password) and sends the code/link to the new address. The same endpoint doubles as registration — no separate signup step is required.
If the user model has a username field, a unique username is auto-generated from the email's local part.
LOGIN_AUTO_SIGNUP defaults to False so that login/ only mails known accounts. Enable it explicitly to opt into the login-doubles-as-signup flow:
BODEPONTOIO = {
"LOGIN_AUTO_SIGNUP": True, # default: False
}login/ is throttled out of the box by IP and by email to protect against abuse (especially relevant when auto-signup is enabled, since it can otherwise be used to mass-create user rows). Defaults are 20/hour per IP and 10/hour per email; tune or disable via:
BODEPONTOIO = {
"LOGIN_THROTTLE_IP_RATE": "20/hour", # set to None to disable
"LOGIN_THROTTLE_EMAIL_RATE": "10/hour", # set to None to disable
}
⚠️ The IP throttle requiresNUM_PROXIESto be configured. DRF derives the client IP fromX-Forwarded-For, which the client controls. Unless you tell DRF how many trusted proxies sit in front of the app, an attacker can rotate that header and get a fresh throttle bucket per request, bypassingLOGIN_THROTTLE_IP_RATEentirely. Set it to match your deployment:REST_FRAMEWORK = { "NUM_PROXIES": 1, # number of trusted proxies (nginx, LB, CDN…); use 0 if none }If you don't know the proxy depth,
0is the safe default (uses the unspoofableREMOTE_ADDR). The per-email throttle is unaffected — it keys on the request body.
The magic-link request accepts an optional next field — a relative path the frontend should navigate to after exchanging the link for tokens:
POST login/
{"email": "user@example.com", "next": "/dashboard"}The backend appends ?next=<url-encoded> to the link in the email. The frontend route at /login/magic/<uid>/<token>/ should read next from location.search, POST {uid, token} to login/magic/confirm/, store the JWT, then navigate to the validated next.
next must be a relative path starting with /. Absolute URLs (https://..., //evil.com), scheme-prefixed values (javascript:), and backslash-prefixed values are rejected with HTTP 400 to prevent open-redirect attacks. The field survives cross-device flows (request on phone, click on laptop) because the destination travels inside the link itself.
POST login/resend/ reissues the OTP code or magic link for an existing account. Shape mirrors login/ ({"email": "...", "next": "..."}) and the response is always 200 to avoid leaking which addresses are registered.
POST login/resend/
{"email": "user@example.com"}Notes:
- Resend never auto-creates an account, even when
LOGIN_AUTO_SIGNUP=True. Unknown emails silently no-op. - Returns 404 when
LOGIN_STRATEGY = "password". - Throttle counters are shared with
POST login/(LOGIN_THROTTLE_IP_RATE,LOGIN_THROTTLE_EMAIL_RATE), so alternating endpoints does not reset the budget. - For OTP, the new code invalidates any prior unused code for that user (the prior one stops working immediately).
- For magic links, the previously emailed link remains usable until the next successful confirm (which updates
last_loginand breaks every outstanding token at once).
A magic link is invalidated as soon as it is used: the confirm view updates last_login, which is part of the token's hash, so the same uid + token pair cannot be replayed.
A successful OTP or magic-link login automatically sets is_email_verified = True if the user's email was not yet confirmed — possession of the inbox is sufficient proof.
login/ always returns 200 even for inactive addresses, but no email is sent. The confirm endpoints reject inactive users with 401.
| Template | Strategy | Description |
|---|---|---|
bodepontoio/login_otp.html |
otp |
Login OTP code email |
bodepontoio/login_magic_link.html |
magic_link |
Login magic link email |
Context variables:
login_otp.html—{{ user }},{{ otp_code }},{{ expiry_minutes }},{{ brand_color }}login_magic_link.html—{{ user }},{{ login_url }},{{ brand_color }}
A styled HTML email (with plain-text fallback) is sent automatically when a user requests a password reset.
Django template override (no config needed): create a file at the same path inside any directory listed in TEMPLATES[0]['DIRS']:
your_project/
templates/
bodepontoio/
password_reset_email.html
| Variable | Description |
|---|---|
user |
The User instance requesting the reset |
reset_url |
Full URL the user should click to reset their password |
All responses share a consistent envelope.
Success:
{
"success": true,
"pagination": false,
"data": { ... }
}Success — empty body (e.g. logout):
{
"success": true
}Success — paginated list (any pagination class using BodePaginationMixin):
{
"success": true,
"pagination": true,
"data": { ... }
}The shape of data depends on the pagination class. For StandardPagination:
{
"success": true,
"pagination": true,
"data": {
"items": [ ... ],
"pagination": {
"page": 1,
"limit": 20,
"total": 100,
"totalPages": 5,
"hasNext": true,
"hasPrev": false
}
}
}Validation error (HTTP 400):
{
"success": false,
"type": "validation_error",
"errors": [
{"field": "email", "message": "Este campo é obrigatório."},
{"field": "non_field_errors", "message": "Credenciais inválidas."}
]
}All other errors:
{
"success": false,
"type": "authentication_error",
"error": "As credenciais de autenticação não foram fornecidas."
}Possible type values:
validation_errorauthentication_errorpermission_errornot_foundparse_errormethod_not_allowednot_acceptableunsupported_media_typethrottled
Unknown exception types fall back to the class name in snake_case.
Mixin that marks paginated responses so SuccessJSONRenderer can wrap them correctly. Any custom pagination class can opt in by inheriting from it:
from bodepontoio.pagination import BodePaginationMixin
from rest_framework.pagination import PageNumberPagination
class MyPagination(BodePaginationMixin, PageNumberPagination):
...A PageNumberPagination subclass (with BodePaginationMixin) with a consistent response shape.
from bodepontoio.pagination import StandardPagination
class MyView(ListAPIView):
pagination_class = StandardPagination| Query param | Default | Max | Description |
|---|---|---|---|
page |
1 |
— | Page number |
limit |
20 |
100 |
Items per page |
from bodepontoio.models import BaseModel, LogicDeletable
class Invoice(LogicDeletable):
...Adds created and updated (both DateTimeField, auto-managed) to any model.
Extends BaseModel with soft deletion. Fields added: excluido (bool), excluido_em (datetime), excluido_por (FK to auth.User, nullable).
# Querying
Invoice.objects.all() # live rows only (default manager)
Invoice.com_excluidos.all() # includes soft-deleted rows
# Instance operations
invoice.delete() # soft delete (excluido=True, excluido_em=now)
invoice.logic_delete(user) # soft delete, records who did it in excluido_por
invoice.reativar() # undo — clears excluido, excluido_em, excluido_porExtends Django's built-in auth.User via a OneToOneField to store auth-related state that doesn't belong on the User model itself. A UserAuth record is created automatically for every new user via a post_save signal — no manual setup required.
Access it via the auth reverse relation:
user.auth.is_email_verified # bool| Field | Type |
|---|---|
user |
OneToOneField to AUTH_USER_MODEL |
is_email_verified |
BooleanField (default: False) |
Country model with 195 pre-loaded entries (see Management Commands).
| Field | Type |
|---|---|
nome |
CharField (unique) |
capital |
CharField |
codigo_3 |
CharField (unique, 3-letter ISO) |
codigo_2 |
CharField (unique, 2-letter ISO) |
Automatically created on every user_logged_in signal via a built-in signal handler. Tracks the user and their IP address.
| Field | Type |
|---|---|
user |
FK to auth.User (nullable) |
ip |
GenericIPAddressField |
Cache for CEP lookups. Populated automatically by the CEP Service.
| Field | Type |
|---|---|
cep |
CharField (unique, formatted XXXXX-XXX) |
logradouro |
CharField |
complemento |
CharField |
bairro |
CharField |
localidade |
CharField |
uf |
CharField |
ibge |
CharField |
ddd |
CharField |
localidade_slug |
SlugField (auto-generated) |
fonte |
CharField (viacep or awesomeapi) |
Tracks images compressed by the compress_images_with_tinify command. Inherits LogicDeletable.
| Field | Type |
|---|---|
path |
CharField |
Looks up Brazilian postal codes with automatic fallback and database caching.
from bodepontoio.services.cep_service import cep_service
dados = cep_service.consultar("01001-000")
# DadosCEP(cep='01001-000', localidade='São Paulo', uf='SP', ...)
# Search cached CEPs by city slug
resultados = cep_service.buscar_por_slug("sao-paulo")Lookup order:
- Local database cache (
ConsultaCEP) - ViaCEP API
- AwesomeAPI (fallback)
Results are saved to the database automatically for future lookups.
Exceptions:
CEPInvalidoError— malformed CEPCEPNaoEncontradoError— not found in any provider
All utilities live under bodepontoio.utils.
from bodepontoio.utils.cleaners import (
file_name_cleaner, # slugify filename, preserve extension
file_extension, # extract file extension
extract_name_and_surname, # split "João Silva" → ("João", "Silva")
get_client_ip, # extract IP from request (X-Forwarded-For aware)
)from bodepontoio.utils.strings import trim_string
trim_string(" hello world ") # "hello world"from bodepontoio.utils.dates import month_to_string
month_to_string(3) # "Março"from bodepontoio.utils.numbers import grana
grana(1234.56) # "1.234,56"
grana(1000, prefixo="R$") # "R$ 1.000,00"Brazilian workday calculator with holidays from 2023 to 2028.
from bodepontoio.utils.workdays import workday, num_workdays
workday(start_date, 5) # 5 business days forward
num_workdays(start_date, end_date) # count business days in rangefrom bodepontoio.utils.email.ofuscate import obfuscate_email
obfuscate_email("user@gmail.com") # "us**@g****.com"from bodepontoio.utils.forms.fields import ValidatingEmailField
# Django form field that checks MX records (requires dnspython)from bodepontoio.utils.pagination import LastPageFixPaginator
# Paginator that returns the last page instead of raising EmptyPagefrom bodepontoio.utils.database.mysql import Round
# Django Func for ROUND(expr, 2)from bodepontoio.utils.passwords.generate_hash import (
hash_password_pbkdf2, # returns "pbkdf2$iterations$salt_hex$hash_hex"
verify_password_pbkdf2, # constant-time verification
){% load bodepontoio_tags %}
{{ valor|grana }} {# 1.234,56 #}
{{ valor|grana:"R$" }} {# R$ 1.234,56 #}
{{ valor|multiply:2 }} {# valor * 2 #}
{{ valor|roi:custo }} {# ((valor - custo) / custo) * 100 #}
{% url_replace request "page" 2 %} {# preserves other query params #}Optional Sentry metrics wrappers. Requires sentry-sdk (pip install "django-bodepontoio[sentry]").
from bodepontoio.metrics import count, distribution, gauge
count("my_event")
distribution("response_time", 0.45, unit="second")
gauge("queue_size", 42)All calls are wrapped in try/except — they silently log errors if Sentry is unavailable.
Imports 195 countries from the bundled paises.csv into the Pais model.
python manage.py bpio_importar_paisesCompresses images larger than 250 KB using TinyPNG. Requires TINYPNG_KEY in settings and the tinify extra.
pip install "django-bodepontoio[tinify]"
python manage.py compress_images_with_tinify --folders media/uploads/Already-compressed images are tracked in OptimizedImageWithTinyPNG and skipped on subsequent runs.