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.
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,6 @@
1
+ from .cache import *
2
+ from .fields import *
3
+ from .manager import *
4
+ from .models import *
5
+ from .response import *
6
+ from .viewsets import *
@@ -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)