epok-toolkit 1.12.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- epok_toolkit/__init__.py +0 -0
- epok_toolkit/apps.py +14 -0
- epok_toolkit/default_settings.py +33 -0
- epok_toolkit/django/__init__.py +6 -0
- epok_toolkit/django/cache.py +130 -0
- epok_toolkit/django/fields.py +30 -0
- epok_toolkit/django/manager.py +38 -0
- epok_toolkit/django/models.py +42 -0
- epok_toolkit/django/response.py +57 -0
- epok_toolkit/django/utils/__init__.py +1 -0
- epok_toolkit/django/utils/magic_link.py +27 -0
- epok_toolkit/django/viewsets.py +98 -0
- epok_toolkit/email/__init__.py +1 -0
- epok_toolkit/email/email_async.py +40 -0
- epok_toolkit/email/engine.py +69 -0
- epok_toolkit/email/templates.py +173 -0
- epok_toolkit/messaging/__init__.py +1 -0
- epok_toolkit/messaging/whatsapp.py +367 -0
- epok_toolkit/messaging/whatsapp_instanced.py +34 -0
- epok_toolkit/pdf/__init__.py +1 -0
- epok_toolkit/pdf/fuentes/Kollektif-Bold.ttf +0 -0
- epok_toolkit/pdf/fuentes/Kollektif-BoldItalic.ttf +0 -0
- epok_toolkit/pdf/fuentes/Kollektif-Italic.ttf +0 -0
- epok_toolkit/pdf/fuentes/Kollektif.ttf +0 -0
- epok_toolkit/pdf/plantillas/Ticket_congrats.png +0 -0
- epok_toolkit/pdf/plantillas/Ticket_congrats2.png +0 -0
- epok_toolkit/pdf/ticket_pdf.py +246 -0
- epok_toolkit-1.12.8.dist-info/METADATA +75 -0
- epok_toolkit-1.12.8.dist-info/RECORD +32 -0
- epok_toolkit-1.12.8.dist-info/WHEEL +5 -0
- epok_toolkit-1.12.8.dist-info/licenses/LICENSE +5 -0
- epok_toolkit-1.12.8.dist-info/top_level.txt +1 -0
epok_toolkit/__init__.py
ADDED
|
File without changes
|
epok_toolkit/apps.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
|
|
4
|
+
class EpokToolkitConfig(AppConfig):
|
|
5
|
+
default_auto_field = 'django.db.models.BigAutoField'
|
|
6
|
+
name = 'epok_toolkit'
|
|
7
|
+
|
|
8
|
+
def ready(self):
|
|
9
|
+
# Importar tus settings por defecto
|
|
10
|
+
from . import default_settings
|
|
11
|
+
|
|
12
|
+
for setting in dir(default_settings):
|
|
13
|
+
if setting.isupper() and not hasattr(settings, setting):
|
|
14
|
+
setattr(settings, setting, getattr(default_settings, setting))
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Configuración de correo electrónico
|
|
2
|
+
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
|
3
|
+
EMAIL_HOST = 'smtp.gmail.com'
|
|
4
|
+
EMAIL_PORT = 465
|
|
5
|
+
EMAIL_USE_SSL = True
|
|
6
|
+
EMAIL_USE_TLS = False
|
|
7
|
+
EMAIL_DEFAULT_FROM_EMAIL = "no-reply@epok.ai"
|
|
8
|
+
EMAIL_HOST_USER = "no-reply@epok.ai"
|
|
9
|
+
EMAIL_HOST_PASSWORD = "your-email-password"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Configuración de whatsApp
|
|
13
|
+
API_KEY = "your-whatsapp-api-key"
|
|
14
|
+
INSTANCE = "instance-id"
|
|
15
|
+
SERVER_URL = "https://your-server-url.com/"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------- TEMPLATE DE CORREO ELECTRÓNICO ---------- #
|
|
20
|
+
TEMPLATES_SETTINGS = {
|
|
21
|
+
"company": {
|
|
22
|
+
"name": "Congrats",
|
|
23
|
+
"email": "info@compania.com",
|
|
24
|
+
"eslogan": "Eslogan sin definir",
|
|
25
|
+
"footer": "¡Nos vemos pronto!<br><em> El equipo de Congrats 🥳</em>"
|
|
26
|
+
},
|
|
27
|
+
"colors": {
|
|
28
|
+
"background": "#f9fafb",
|
|
29
|
+
"primary": "#4f46e5",
|
|
30
|
+
"text": "#374151",
|
|
31
|
+
"white": "#ffffff"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# utils/cacher.py
|
|
2
|
+
from rest_framework import viewsets
|
|
3
|
+
from rest_framework_extensions.cache.mixins import CacheResponseMixin
|
|
4
|
+
from rest_framework_extensions.cache.decorators import cache_response
|
|
5
|
+
from django.views.decorators.cache import cache_page
|
|
6
|
+
from colorstreak import Logger as log
|
|
7
|
+
from functools import wraps
|
|
8
|
+
from django.utils.decorators import method_decorator
|
|
9
|
+
# --- clave por usuario + params + página ----------------------
|
|
10
|
+
from rest_framework_extensions.key_constructor.constructors import DefaultKeyConstructor
|
|
11
|
+
from rest_framework_extensions.key_constructor.bits import UserKeyBit, QueryParamsKeyBit, PaginationKeyBit
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
cacher.py — utilidades de caché centralizadas
|
|
16
|
+
============================================
|
|
17
|
+
|
|
18
|
+
Este módulo agrupa, en un solo lugar, las dos capas de caché que aplicamos en
|
|
19
|
+
nuestra API DRF:
|
|
20
|
+
|
|
21
|
+
📦 cache_get → capa de *serialización* (drf extensions @cache_response)
|
|
22
|
+
🚀 cache_full → capa de *vista completa* (Django @cache_page)
|
|
23
|
+
|
|
24
|
+
Para no repetir la misma lógica en cada vista, exportamos:
|
|
25
|
+
|
|
26
|
+
• `CachedViewSet` -> incluye CacheResponseMixin listo para usar.
|
|
27
|
+
• Decoradores -> `cache_get`, `cache_full`.
|
|
28
|
+
• `TimeToLive` -> constantes de segundos ligadas a colores/emoji.
|
|
29
|
+
|
|
30
|
+
Leyenda TTL (la misma del CSV)
|
|
31
|
+
------------------------------
|
|
32
|
+
🔴 1 min (60 s) | cambios frecuentes
|
|
33
|
+
🟡 2 --> 5 min (~300 s) | lecturas comunes
|
|
34
|
+
🟢 10 --> 30 min (~1800 s) | datos casi estáticos
|
|
35
|
+
|
|
36
|
+
Emojis ↔ capas
|
|
37
|
+
--------------
|
|
38
|
+
🗄️ consultas ORM (cacheops / get_or_set)
|
|
39
|
+
📦 serialización (cache_response)
|
|
40
|
+
🚀 vista completa (cache_page)
|
|
41
|
+
🌐 CDN / navegador (Cache‑Control)
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TimeToLive:
|
|
48
|
+
"""
|
|
49
|
+
Constantes de TTL ligadas a la paleta del CSV:
|
|
50
|
+
|
|
51
|
+
🔴 RED | 1 min (60 s)
|
|
52
|
+
🟡 YELLOW | 5 min (300 s)
|
|
53
|
+
🟢 GREEN | 30 min (1800 s)
|
|
54
|
+
"""
|
|
55
|
+
RED = 60 # 1 minuto
|
|
56
|
+
YELLOW = 60 * 5 # 5 minutos
|
|
57
|
+
GREEN = 60 * 30 # 30 minutos
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class UserQueryKey(DefaultKeyConstructor):
|
|
61
|
+
user = UserKeyBit()
|
|
62
|
+
query_params = QueryParamsKeyBit()
|
|
63
|
+
pagination = PaginationKeyBit()
|
|
64
|
+
|
|
65
|
+
_DEFAULT_KEY = UserQueryKey().get_key
|
|
66
|
+
_DEFAULT_TTL = TimeToLive.RED
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CachedViewSet(CacheResponseMixin, viewsets.ModelViewSet):
|
|
71
|
+
"""
|
|
72
|
+
Herédame si vas a usar cache_get o cache_full.
|
|
73
|
+
Nada más que eso; no impone TTL.
|
|
74
|
+
"""
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cache_get(ttl=_DEFAULT_TTL, key_func=_DEFAULT_KEY):
|
|
81
|
+
"""
|
|
82
|
+
Capa: Serialización 📦
|
|
83
|
+
Devuelve un decorador que cachea la serialización DRF *una sola vez*,
|
|
84
|
+
evitando envolver la vista nuevamente en cada petición.
|
|
85
|
+
|
|
86
|
+
Ejemplo:
|
|
87
|
+
@cache_get(ttl=TimeToLive.RED)
|
|
88
|
+
def view(...): ...
|
|
89
|
+
"""
|
|
90
|
+
def decorator(view_fn):
|
|
91
|
+
# Pre‑construimos la función cacheada UNA vez
|
|
92
|
+
cached_fn = cache_response(ttl, key_func=key_func)(view_fn)
|
|
93
|
+
|
|
94
|
+
@wraps(view_fn)
|
|
95
|
+
def wrapped(*args, **kwargs):
|
|
96
|
+
log.library(
|
|
97
|
+
f"[📦 cache_get] {view_fn.__qualname__} | ttl={ttl}s"
|
|
98
|
+
)
|
|
99
|
+
# Llamamos directamente a la versión ya decorada,
|
|
100
|
+
# para no crear cadenas de closures ni excepciones duplicadas.
|
|
101
|
+
return cached_fn(*args, **kwargs)
|
|
102
|
+
|
|
103
|
+
return wrapped
|
|
104
|
+
|
|
105
|
+
return decorator
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cache_full(ttl=_DEFAULT_TTL, key_prefix=""):
|
|
109
|
+
"""
|
|
110
|
+
Capa: Respuesta HTTP 🚀
|
|
111
|
+
Devuelve un decorador que cachea la vista completa vía cache_page
|
|
112
|
+
*al momento de ejecutar la vista*.
|
|
113
|
+
|
|
114
|
+
Ejemplo:
|
|
115
|
+
@cache_full(ttl=TimeToLive.GREEN, key_prefix="ticket_pdf")
|
|
116
|
+
def view(...): ...
|
|
117
|
+
"""
|
|
118
|
+
def decorator(view_fn):
|
|
119
|
+
# Adapt the function-level cache_page decorator to a bound‑method
|
|
120
|
+
page_deco = cache_page(ttl, key_prefix=key_prefix)
|
|
121
|
+
decorated_fn = method_decorator(page_deco)(view_fn)
|
|
122
|
+
|
|
123
|
+
@wraps(view_fn)
|
|
124
|
+
def wrapped(self, request, *args, **kwargs):
|
|
125
|
+
log.library(f"[🚀 cache_full] {view_fn.__qualname__} | ttl={ttl}s | prefix={key_prefix}")
|
|
126
|
+
return decorated_fn(self, request, *args, **kwargs)
|
|
127
|
+
|
|
128
|
+
return wrapped
|
|
129
|
+
|
|
130
|
+
return decorator
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
|
|
4
|
+
class StandarFloatField(models.FloatField):
|
|
5
|
+
def __init__(self, *args, **kwargs):
|
|
6
|
+
kwargs.setdefault('default', 0.0)
|
|
7
|
+
super().__init__(*args, **kwargs)
|
|
8
|
+
|
|
9
|
+
class StandarDecimalField(models.DecimalField):
|
|
10
|
+
def __init__(self, *args, **kwargs):
|
|
11
|
+
kwargs.setdefault('max_digits', 10)
|
|
12
|
+
kwargs.setdefault('decimal_places', 2)
|
|
13
|
+
kwargs.setdefault('default', Decimal('0.00'))
|
|
14
|
+
super().__init__(*args, **kwargs)
|
|
15
|
+
|
|
16
|
+
class StandarIntegerField(models.IntegerField):
|
|
17
|
+
def __init__(self, *args, **kwargs):
|
|
18
|
+
kwargs.setdefault('default', 0)
|
|
19
|
+
super().__init__(*args, **kwargs)
|
|
20
|
+
|
|
21
|
+
class StandarCharField(models.CharField):
|
|
22
|
+
def __init__(self, *args, **kwargs):
|
|
23
|
+
kwargs.setdefault('max_length', 20)
|
|
24
|
+
kwargs.setdefault('default', 'Not specified')
|
|
25
|
+
super().__init__(*args, **kwargs)
|
|
26
|
+
|
|
27
|
+
class StandarDateTimeField(models.DateTimeField):
|
|
28
|
+
def __init__(self, *args, **kwargs):
|
|
29
|
+
kwargs.setdefault('auto_now_add', True)
|
|
30
|
+
super().__init__(*args, **kwargs)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
|
|
3
|
+
class OptimizedQuerySet(models.QuerySet):
|
|
4
|
+
def optimized(self, select_fields=None, prefetch_fields=None):
|
|
5
|
+
qs = self
|
|
6
|
+
if select_fields:
|
|
7
|
+
qs = qs.select_related(*select_fields)
|
|
8
|
+
if prefetch_fields:
|
|
9
|
+
qs = qs.prefetch_related(*prefetch_fields)
|
|
10
|
+
return qs
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OptimizedManager(models.Manager):
|
|
14
|
+
"""
|
|
15
|
+
Custom manager for optimized querysets.
|
|
16
|
+
"""
|
|
17
|
+
def __init__(self, select_fields_full=None, prefetch_fields_full=None, select_fields_simple=None, prefetch_fields_simple=None, *args, **kwargs):
|
|
18
|
+
|
|
19
|
+
super().__init__(*args, **kwargs)
|
|
20
|
+
self._select_fields_full = select_fields_full or []
|
|
21
|
+
self._prefetch_fields_full = prefetch_fields_full or []
|
|
22
|
+
self._select_fields_simple = select_fields_simple or []
|
|
23
|
+
self._prefetch_fields_simple = prefetch_fields_simple or []
|
|
24
|
+
|
|
25
|
+
def get_queryset(self):
|
|
26
|
+
return OptimizedQuerySet(self.model, using=self._db)
|
|
27
|
+
|
|
28
|
+
def full(self):
|
|
29
|
+
return self.get_queryset().optimized(
|
|
30
|
+
select_fields=self._select_fields_full,
|
|
31
|
+
prefetch_fields=self._prefetch_fields_full
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def simple(self):
|
|
35
|
+
return self.get_queryset().optimized(
|
|
36
|
+
select_fields=self._select_fields_simple,
|
|
37
|
+
prefetch_fields=self._prefetch_fields_simple
|
|
38
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from uuid import uuid4
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
from django.db import models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class UUID4Mixin(models.Model):
|
|
8
|
+
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
|
9
|
+
|
|
10
|
+
class Meta:
|
|
11
|
+
abstract = True
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TimeStampMixin(models.Model):
|
|
16
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
17
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
18
|
+
|
|
19
|
+
class Meta:
|
|
20
|
+
abstract = True
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SoftDeleteMixin(models.Model):
|
|
25
|
+
is_deleted = models.BooleanField(default=False)
|
|
26
|
+
|
|
27
|
+
class Meta:
|
|
28
|
+
abstract = True
|
|
29
|
+
indexes = [
|
|
30
|
+
models.Index(fields=['is_deleted']),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CreatorsMixin(models.Model):
|
|
36
|
+
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True,
|
|
37
|
+
on_delete=models.CASCADE, related_name='%(class)s_created')
|
|
38
|
+
updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True,
|
|
39
|
+
on_delete=models.CASCADE, related_name='%(class)s_updated')
|
|
40
|
+
|
|
41
|
+
class Meta:
|
|
42
|
+
abstract = True
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from rest_framework.response import Response
|
|
2
|
+
from django.http import HttpResponse
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
STATUS_MESSAGES = {
|
|
7
|
+
200: "OK",
|
|
8
|
+
201: "Created",
|
|
9
|
+
202: "Accepted",
|
|
10
|
+
204: "No Content",
|
|
11
|
+
400: "Bad Request",
|
|
12
|
+
401: "Unauthorized",
|
|
13
|
+
403: "Forbidden",
|
|
14
|
+
404: "Not Found",
|
|
15
|
+
500: "Internal Server Error",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def response(data: Optional[dict] = None, status_code: int = 200, message: Optional[str] = None) -> Response:
|
|
21
|
+
success = True if 200 <= status_code < 300 else False
|
|
22
|
+
|
|
23
|
+
if status_code not in STATUS_MESSAGES:
|
|
24
|
+
raise ValueError(f"Invalid status code: {status_code}. Must be one of {list(STATUS_MESSAGES.keys())}.")
|
|
25
|
+
|
|
26
|
+
if status_code == 201 and not data:
|
|
27
|
+
raise ValueError("Data cannot be empty for status code 201 (Created).")
|
|
28
|
+
|
|
29
|
+
if status_code == 204 and data is not None:
|
|
30
|
+
raise ValueError("Data must be None for status code 204 (No Content).")
|
|
31
|
+
|
|
32
|
+
if not message:
|
|
33
|
+
message = STATUS_MESSAGES[status_code]
|
|
34
|
+
|
|
35
|
+
if not data:
|
|
36
|
+
data = {}
|
|
37
|
+
|
|
38
|
+
payload = {
|
|
39
|
+
"status": success,
|
|
40
|
+
"message": message
|
|
41
|
+
}
|
|
42
|
+
if success and data is not None:
|
|
43
|
+
payload["data"] = data
|
|
44
|
+
elif not success:
|
|
45
|
+
payload["error"] = data
|
|
46
|
+
return Response(payload, status=status_code)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def file_response(file: bytes, filename: str, content_type: str = "application/octet-stream", status_code: int = 200) -> HttpResponse:
|
|
51
|
+
"""
|
|
52
|
+
Returns a file response with the given file content, filename, and content type.
|
|
53
|
+
"""
|
|
54
|
+
response = HttpResponse(file, status=status_code)
|
|
55
|
+
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
|
56
|
+
response["Content-Type"] = content_type
|
|
57
|
+
return response
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .magic_link import *
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
import jwt
|
|
3
|
+
from django.utils import timezone
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
|
|
6
|
+
def generate_login_magic_token(user, minutes_valid=10):
|
|
7
|
+
"""
|
|
8
|
+
Genera un token JWT válido por X minutos para login sin contraseña.
|
|
9
|
+
Incluye un propósito específico para distinguirlo de otros tipos de token.
|
|
10
|
+
"""
|
|
11
|
+
payload = {
|
|
12
|
+
"user_id": str(user.id),
|
|
13
|
+
"exp": timezone.now() + timedelta(minutes=minutes_valid),
|
|
14
|
+
"purpose": "magic_login",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
token = jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
|
18
|
+
if isinstance(token, bytes):
|
|
19
|
+
token = token.decode("utf-8")
|
|
20
|
+
return token
|
|
21
|
+
|
|
22
|
+
def build_login_magic_link(token):
|
|
23
|
+
"""
|
|
24
|
+
Construye el enlace completo hacia tu frontend.
|
|
25
|
+
Ajusta la ruta a donde quieras que el frontend reciba el token.
|
|
26
|
+
"""
|
|
27
|
+
return f"{settings.FRONTEND_URL}#/magic-login?token={token}"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# core/viewsets.py
|
|
2
|
+
from rest_framework import viewsets
|
|
3
|
+
from rest_framework.pagination import PageNumberPagination
|
|
4
|
+
from rest_framework.permissions import IsAuthenticated
|
|
5
|
+
from colorstreak import Logger as log
|
|
6
|
+
|
|
7
|
+
class DefaultPagination(PageNumberPagination):
|
|
8
|
+
"""
|
|
9
|
+
Clase de paginación por defecto para los ViewSets.
|
|
10
|
+
Puedes personalizarla según tus necesidades.
|
|
11
|
+
"""
|
|
12
|
+
page_size = 10
|
|
13
|
+
page_size_query_param = 'page_size'
|
|
14
|
+
max_page_size = 100
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaseOptimizedViewSet(viewsets.ModelViewSet):
|
|
19
|
+
"""
|
|
20
|
+
Clase base para ViewSets optimizados.
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
queryset = None
|
|
24
|
+
write_serializer_class = None
|
|
25
|
+
update_serializer_class = None
|
|
26
|
+
simple_serializer_class = None
|
|
27
|
+
full_serializer_class = None
|
|
28
|
+
serializer_class = None
|
|
29
|
+
extensions_auto_optimize = True
|
|
30
|
+
|
|
31
|
+
permission_classes = [IsAuthenticated]
|
|
32
|
+
|
|
33
|
+
pagination_class = DefaultPagination
|
|
34
|
+
|
|
35
|
+
filterset_fields = []
|
|
36
|
+
search_fields = []
|
|
37
|
+
ordering_fields = []
|
|
38
|
+
ordering = []
|
|
39
|
+
|
|
40
|
+
def get_queryset(self):
|
|
41
|
+
# aqui heredamos de la libreria estandar
|
|
42
|
+
qs = super().get_queryset()
|
|
43
|
+
model_cls = qs.model
|
|
44
|
+
manager = model_cls._default_manager
|
|
45
|
+
|
|
46
|
+
if hasattr(manager, 'simple') and self.action == 'list':
|
|
47
|
+
#log.library("| LIBRERIA | Usando QS simple")
|
|
48
|
+
qs = manager.simple()
|
|
49
|
+
elif hasattr(manager, 'full'):
|
|
50
|
+
#log.library("| LIBRERIA | Usando QS full")
|
|
51
|
+
qs = manager.full()
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
#log.library("| LIBRERIA | Filtrando por created_by")
|
|
55
|
+
qs_created_by= qs.filter(created_by=self.request.user)
|
|
56
|
+
return qs_created_by
|
|
57
|
+
except Exception as e:
|
|
58
|
+
log.error(f"| LIBRERIA | Error al filtrar por created_by: {e}")
|
|
59
|
+
log.info("| LIBRERIA | Filtrado sin created_by")
|
|
60
|
+
return qs
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_serializer_class(self):
|
|
65
|
+
|
|
66
|
+
match self.action:
|
|
67
|
+
case 'create' if self.write_serializer_class is not None:
|
|
68
|
+
return self.write_serializer_class
|
|
69
|
+
case 'update' | 'partial_update' if self.update_serializer_class is not None and self.write_serializer_class is not None:
|
|
70
|
+
return self.update_serializer_class
|
|
71
|
+
case 'list' if self.simple_serializer_class is not None:
|
|
72
|
+
return self.simple_serializer_class
|
|
73
|
+
case 'retrieve' if self.full_serializer_class is not None:
|
|
74
|
+
return self.full_serializer_class
|
|
75
|
+
case _ if self.serializer_class is not None:
|
|
76
|
+
# log.warning(f"| LIBRERIA | No se encontró serializer específico para la acción '{self.action}', usando el por defecto.")
|
|
77
|
+
return self.serializer_class
|
|
78
|
+
case _:
|
|
79
|
+
log.error("| LIBRERIA | No se encontró serializer por defecto")
|
|
80
|
+
raise ValueError("No se encontró serializer por defecto")
|
|
81
|
+
|
|
82
|
+
def perform_create(self, serializer):
|
|
83
|
+
try:
|
|
84
|
+
#log.library("| LIBRERIA |Guardando con created_by y updated_by")
|
|
85
|
+
serializer.save(created_by=self.request.user, updated_by=self.request.user)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
log.error(f"| LIBRERIA | Error al guardar: {e}")
|
|
88
|
+
log.info("| LIBRERIA | Guardando sin created_by y updated_by")
|
|
89
|
+
serializer.save()
|
|
90
|
+
|
|
91
|
+
def perform_update(self, serializer):
|
|
92
|
+
try:
|
|
93
|
+
#log.library("| LIBRERIA | Guardando con updated_by")
|
|
94
|
+
serializer.save(updated_by=self.request.user)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
log.error(f"| LIBRERIA | Error al actualizar: {e}")
|
|
97
|
+
log.info("| LIBRERIA | Guardando sin updated_by")
|
|
98
|
+
serializer.save()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .email_async import send_email
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import smtplib
|
|
5
|
+
from celery import shared_task, states
|
|
6
|
+
from celery.exceptions import Ignore
|
|
7
|
+
from .engine import EmailEngine
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
@shared_task(
|
|
12
|
+
bind=True,
|
|
13
|
+
autoretry_for=(smtplib.SMTPException, ConnectionError, TimeoutError),
|
|
14
|
+
retry_backoff=True, # 2, 4, 8, 16… s
|
|
15
|
+
retry_jitter=True, # +- aleatorio
|
|
16
|
+
max_retries=3,
|
|
17
|
+
ignore_result=True, # <-- ¡importante!
|
|
18
|
+
)
|
|
19
|
+
def send_email_async(self, template_key, context, recipient, attachments=None):
|
|
20
|
+
try:
|
|
21
|
+
engine = EmailEngine()
|
|
22
|
+
engine.send(
|
|
23
|
+
template_key=template_key,
|
|
24
|
+
context=context,
|
|
25
|
+
recipient=recipient,
|
|
26
|
+
attachments=attachments,
|
|
27
|
+
)
|
|
28
|
+
# No retornamos nada; evitamos que Celery/Redis serialice blobs.
|
|
29
|
+
except smtplib.SMTPSenderRefused as exc:
|
|
30
|
+
# 552 => el servidor rechaza tamaño. No vale la pena reintentar.
|
|
31
|
+
if exc.smtp_code == 552:
|
|
32
|
+
log.error("SMTP 552 – tamaño excedido; abortando reintentos: %s", exc)
|
|
33
|
+
# Marcamos la tarea como FAILURE, pero NO reintentamos.
|
|
34
|
+
self.update_state(state=states.FAILURE, meta=str(exc))
|
|
35
|
+
raise Ignore() # Detiene la cadena Celery sin más
|
|
36
|
+
# Otros códigos (550, 451, etc.) siguen flujo normal de retry
|
|
37
|
+
raise
|
|
38
|
+
|
|
39
|
+
def send_email(template_key, context, recipient, attachments=None):
|
|
40
|
+
send_email_async.delay(template_key, context, recipient, attachments) # type: ignore
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
notifications/method/email_engine.py
|
|
3
|
+
Motor de envío de correos basado en plantillas del catálogo TEMPLATES.
|
|
4
|
+
La vista sólo indica qué plantilla usar, el contexto y el destinatario.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Dict
|
|
8
|
+
from django.core.mail import send_mail
|
|
9
|
+
from django.conf import settings
|
|
10
|
+
|
|
11
|
+
from .templates import TEMPLATES, EmailTemplate, RenderedEmail
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EmailEngine:
|
|
15
|
+
"""
|
|
16
|
+
Ejemplo de uso:
|
|
17
|
+
|
|
18
|
+
engine = EmailEngine()
|
|
19
|
+
engine.send(
|
|
20
|
+
template_key="password_reset",
|
|
21
|
+
context={"name": "Fer", "reset_link": "https://app.congrats.mx/#/reset/abc"},
|
|
22
|
+
recipient="fer@example.com",
|
|
23
|
+
)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, templates: Dict[str, EmailTemplate] = TEMPLATES, backend=send_mail):
|
|
27
|
+
self.templates = templates
|
|
28
|
+
self.backend = backend
|
|
29
|
+
|
|
30
|
+
# --------------------------------------------------------------------- #
|
|
31
|
+
# API pública
|
|
32
|
+
# --------------------------------------------------------------------- #
|
|
33
|
+
def send(
|
|
34
|
+
self,
|
|
35
|
+
template_key: "str", # TODO: PONER CLASE VALIDADOR DE TEMPLATE EVITAR INYECCION
|
|
36
|
+
context: Dict[str, str],
|
|
37
|
+
recipient: str,
|
|
38
|
+
attachments: Dict[str, bytes] | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Envía un correo usando la plantilla indicada.
|
|
42
|
+
- `context` debe contener todas las llaves declaradas en `required_vars`.
|
|
43
|
+
- `attachments` puede ser un dict {filename: bytes} para adjuntar PDFs u otros ficheros.
|
|
44
|
+
"""
|
|
45
|
+
# Verificar plantilla
|
|
46
|
+
if template_key not in self.templates:
|
|
47
|
+
raise KeyError(f"Plantilla '{template_key}' no existe en TEMPLATES.") # TODO: PONER CUALES SI SON VALIDAS
|
|
48
|
+
|
|
49
|
+
# Renderizar cuerpo
|
|
50
|
+
rendered: RenderedEmail = self.templates[template_key].render(context)
|
|
51
|
+
|
|
52
|
+
# Crear e‑mail multipart (texto + HTML)
|
|
53
|
+
from django.core.mail import EmailMultiAlternatives
|
|
54
|
+
|
|
55
|
+
message = EmailMultiAlternatives(
|
|
56
|
+
subject=rendered.subject,
|
|
57
|
+
body=rendered.plain,
|
|
58
|
+
from_email=settings.EMAIL_HOST_USER,
|
|
59
|
+
to=[recipient],
|
|
60
|
+
)
|
|
61
|
+
message.attach_alternative(rendered.html, "text/html")
|
|
62
|
+
|
|
63
|
+
# Adjuntos opcionales
|
|
64
|
+
if attachments:
|
|
65
|
+
for name, data in attachments.items():
|
|
66
|
+
message.attach(name, data, "application/pdf")
|
|
67
|
+
|
|
68
|
+
# Enviar
|
|
69
|
+
message.send(fail_silently=False)
|