📁 Estructura Completa del Proyecto
proyecto_django/ │ ├── 📁 biblioteca_digital/ # Proyecto Django principal │ ├── 🐍 __init__.py │ ├── 🐍 settings.py # Configuración principal ⭐ │ ├── 🐍 urls.py # URLs principales │ ├── 🐍 asgi.py # ASGI config para WebSockets │ └── 🐍 wsgi.py # WSGI config │ ├── 📁 libros/ # Aplicación de Biblioteca Digital 📚 │ ├── 🐍 __init__.py │ ├── 🐍 models.py # Modelos: Libro, Autor, Categoría, Préstamo ⭐ │ ├── 🐍 serializers.py # Serializadores DRF ⭐ │ ├── 🐍 views.py # Vistas y endpoints API ⭐ │ ├── 🐍 urls.py # URLs de la API ⭐ │ ├── 🐍 authentication.py # JWT & OAuth ⭐ │ ├── 🐍 permissions.py # Permisos personalizados │ ├── 🐍 consumers.py # WebSocket consumers para chat ⭐ │ ├── 🐍 routing.py # WebSocket routing ⭐ │ ├── 🐍 admin.py # Panel de administración │ ├── 🐍 apps.py # Configuración de la app │ └── 🐍 tests.py # Tests unitarios │ ├── 📁 templates/ # Templates HTML │ ├── 🌐 base.html # Template base ⭐ │ ├── 📁 auth/ # Templates de autenticación │ │ ├── 🌐 login.html # Login con JWT ⭐ │ │ └── 🌐 register.html # Registro de usuarios ⭐ │ ├── 📁 libros/ # Templates de libros │ │ ├── 🌐 catalogo.html # Catálogo de libros ⭐ │ │ └── 🌐 detalle.html # Detalle de libro │ ├── 📁 prestamos/ # Templates de préstamos │ │ └── 🌐 mis_prestamos.html # Préstamos del usuario ⭐ │ ├── 🌐 dashboard.html # Dashboard principal ⭐ │ ├── 🌐 chat.html # Chat WebSocket ⭐ │ └── 🌐 index.html # Página principal │ ├── 📁 static/ # Archivos estáticos │ ├── 📁 css/ │ │ ├── 🎨 style.css # Estilos principales ⭐ │ │ ├── 🎨 dashboard.css # Estilos dashboard │ │ └── 🎨 libros.css # Estilos catálogo │ ├── 📁 js/ │ │ ├── ⚡ auth.js # Autenticación JWT ⭐ │ │ ├── ⚡ websocket.js # WebSocket cliente ⭐ │ │ ├── ⚡ oauth.js # OAuth cliente ⭐ │ │ ├── ⚡ libros.js # Gestión de libros ⭐ │ │ └── ⚡ prestamos.js # Gestión de préstamos │ └── 📁 img/ │ └── logo.png │ ├── 📁 media/ # Archivos subidos por usuarios │ ├── avatars/ # Avatares de usuarios │ ├── libros/portadas/ # Portadas de libros │ └── autores/ # Fotos de autores │ ├── ⚙️ requirements.txt # Dependencias Python ⭐ ├── ⚙️ .env # Variables de entorno ⭐ ├── ⚙️ .env.example # Ejemplo de .env ├── ⚙️ manage.py # Script de gestión Django ├── 🐳 Dockerfile # Docker para producción ⭐ ├── 🐳 docker-compose.yml # Orquestación Docker ⭐ └── 📄 README.md # Documentación
✅ Total de archivos: 40+ archivos con código completo
📦 Tecnologías: Django 4.2, DRF, MySQL, JWT, OAuth2, WebSockets, Docker
📚 Proyecto: Sistema de Biblioteca Digital con catálogo, préstamos y reseñas
📦 Tecnologías: Django 4.2, DRF, MySQL, JWT, OAuth2, WebSockets, Docker
📚 Proyecto: Sistema de Biblioteca Digital con catálogo, préstamos y reseñas
⚙️ Archivos de Configuración
requirements.txt
DEPENDENCIAS
📍 proyecto_django/requirements.txt
# ======================================== # DEPENDENCIAS DEL PROYECTO DJANGO # ======================================== # Framework Django Django==4.2.7 djangorestframework==3.14.0 # Base de datos MySQL mysqlclient==2.2.0 # Autenticación JWT djangorestframework-simplejwt==5.3.0 PyJWT==2.8.0 # OAuth 2.0 django-allauth==0.57.0 requests-oauthlib==1.3.1 social-auth-app-django==5.4.0 # WebSockets channels==4.0.0 channels-redis==4.1.0 daphne==4.0.0 # CORS (para APIs) django-cors-headers==4.3.0 # Variables de entorno python-decouple==3.8 python-dotenv==1.0.0 # Validación y seguridad django-csp==3.7 django-ratelimit==4.1.0 # Producción gunicorn==21.2.0 whitenoise==6.6.0 # Redis (para WebSockets y cache) redis==5.0.1 # Utilidades Pillow==10.1.0 celery==5.3.4
.env.example
VARIABLES DE ENTORNO
📍 proyecto_django/.env.example
# ======================================== # VARIABLES DE ENTORNO - COPIAR A .env # ======================================== # Django SECRET_KEY=tu-clave-secreta-super-segura-aqui-cambiar DEBUG=True ALLOWED_HOSTS=localhost,127.0.0.1 # Base de datos MySQL DB_ENGINE=django.db.backends.mysql DB_NAME=mi_base_datos DB_USER=root DB_PASSWORD=tu_password_mysql DB_HOST=localhost DB_PORT=3306 # JWT JWT_SECRET_KEY=otra-clave-secreta-para-jwt JWT_ACCESS_TOKEN_LIFETIME=60 JWT_REFRESH_TOKEN_LIFETIME=1440 # OAuth Google GOOGLE_OAUTH_CLIENT_ID=tu-client-id-de-google.apps.googleusercontent.com GOOGLE_OAUTH_CLIENT_SECRET=tu-client-secret-de-google GOOGLE_OAUTH_REDIRECT_URI=http://localhost:8000/api/auth/google/callback/ # OAuth GitHub GITHUB_OAUTH_CLIENT_ID=tu-client-id-de-github GITHUB_OAUTH_CLIENT_SECRET=tu-client-secret-de-github GITHUB_OAUTH_REDIRECT_URI=http://localhost:8000/api/auth/github/callback/ # WebSockets Redis REDIS_HOST=localhost REDIS_PORT=6379 REDIS_DB=0 # Email (opcional) EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend EMAIL_HOST=smtp.gmail.com EMAIL_PORT=587 EMAIL_USE_TLS=True EMAIL_HOST_USER=tu-email@gmail.com EMAIL_HOST_PASSWORD=tu-password-de-app # CORS CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8000 # Producción SECURE_SSL_REDIRECT=False SESSION_COOKIE_SECURE=False CSRF_COOKIE_SECURE=False
settings.py
CONFIGURACIÓN PRINCIPAL
📍 biblioteca_digital/settings.py
from pathlib import Path
from datetime import timedelta
from decouple import config
# Build paths
BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY
SECRET_KEY = config('SECRET_KEY', default='django-insecure-change-this-in-production')
DEBUG = config('DEBUG', default=True, cast=bool)
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='localhost,127.0.0.1').split(',')
# INSTALLED APPS
INSTALLED_APPS = [
'daphne', # Para WebSockets (debe ir primero)
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third party apps
'rest_framework',
'rest_framework_simplejwt',
'corsheaders',
'channels',
# OAuth
'django.contrib.sites',
'allauth',
'allauth.account',
'allauth.socialaccount',
'allauth.socialaccount.providers.google',
'allauth.socialaccount.providers.github',
# Local apps
'libros', # App principal del proyecto
]
SITE_ID = 1
# MIDDLEWARE
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # CORS debe ir primero
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'biblioteca_digital.urls'
# TEMPLATES
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
# WSGI & ASGI
WSGI_APPLICATION = 'biblioteca_digital.wsgi.application'
ASGI_APPLICATION = 'biblioteca_digital.asgi.application'
# DATABASE - MySQL
DATABASES = {
'default': {
'ENGINE': config('DB_ENGINE', default='django.db.backends.mysql'),
'NAME': config('DB_NAME', default='mi_base_datos'),
'USER': config('DB_USER', default='root'),
'PASSWORD': config('DB_PASSWORD', default=''),
'HOST': config('DB_HOST', default='localhost'),
'PORT': config('DB_PORT', default='3306'),
'OPTIONS': {
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
'charset': 'utf8mb4',
}
}
}
# CHANNELS (WebSockets)
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [(config('REDIS_HOST', default='localhost'),
config('REDIS_PORT', default=6379, cast=int))],
},
},
}
# REST FRAMEWORK
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 10,
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
],
}
# JWT SETTINGS
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=config('JWT_ACCESS_TOKEN_LIFETIME', default=60, cast=int)),
'REFRESH_TOKEN_LIFETIME': timedelta(minutes=config('JWT_REFRESH_TOKEN_LIFETIME', default=1440, cast=int)),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
'ALGORITHM': 'HS256',
'SIGNING_KEY': config('JWT_SECRET_KEY', default=SECRET_KEY),
'AUTH_HEADER_TYPES': ('Bearer',),
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
}
# OAUTH SETTINGS
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend',
]
SOCIALACCOUNT_PROVIDERS = {
'google': {
'SCOPE': ['profile', 'email'],
'AUTH_PARAMS': {'access_type': 'online'},
'APP': {
'client_id': config('GOOGLE_OAUTH_CLIENT_ID', default=''),
'secret': config('GOOGLE_OAUTH_CLIENT_SECRET', default=''),
'key': ''
}
},
'github': {
'SCOPE': ['user', 'repo', 'read:org'],
'APP': {
'client_id': config('GITHUB_OAUTH_CLIENT_ID', default=''),
'secret': config('GITHUB_OAUTH_CLIENT_SECRET', default=''),
}
}
}
# CORS
CORS_ALLOWED_ORIGINS = config(
'CORS_ALLOWED_ORIGINS',
default='http://localhost:3000,http://localhost:8000'
).split(',')
CORS_ALLOW_CREDENTIALS = True
# PASSWORD VALIDATION
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
# INTERNATIONALIZATION
LANGUAGE_CODE = 'es-es'
TIME_ZONE = 'America/Mexico_City'
USE_I18N = True
USE_TZ = True
# STATIC FILES
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [BASE_DIR / 'static']
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
# MEDIA FILES
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# DEFAULT PRIMARY KEY
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# SECURITY (Producción)
if not DEBUG:
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'DENY'
urls.py (Principal)
RUTAS PRINCIPALES
📍 biblioteca_digital/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('libros.urls')), # API y templates de la app libros
path('accounts/', include('allauth.urls')), # OAuth URLs
path('', include('libros.urls')), # Rutas base (templates)
]
# Servir archivos estáticos y media en desarrollo
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
asgi.py
ASGI WEBSOCKETS
📍 biblioteca_digital/asgi.py
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from channels.security.websocket import AllowedHostsOriginValidator
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'biblioteca_digital.settings')
django_asgi_app = get_asgi_application()
from libros.routing import websocket_urlpatterns
application = ProtocolTypeRouter({
"http": django_asgi_app,
"websocket": AllowedHostsOriginValidator(
AuthMiddlewareStack(
URLRouter(websocket_urlpatterns)
)
),
})
🐍 Backend - Archivos Python de la App LIBROS
models.py
MODELOS
📍 libros/models.py
from django.db import models
from django.contrib.auth.models import User
from django.core.validators import MinValueValidator, MaxValueValidator
class UserProfile(models.Model):
"""Perfil extendido del usuario"""
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
bio = models.TextField(max_length=500, blank=True)
avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)
phone = models.CharField(max_length=20, blank=True)
birth_date = models.DateField(null=True, blank=True)
# OAuth
google_id = models.CharField(max_length=255, blank=True, null=True)
github_id = models.CharField(max_length=255, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f'Profile: {self.user.username}'
class Meta:
db_table = 'user_profiles'
verbose_name = 'Perfil de Usuario'
verbose_name_plural = 'Perfiles de Usuarios'
class Categoria(models.Model):
"""Categorías de libros"""
nombre = models.CharField(max_length=100, unique=True)
descripcion = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.nombre
class Meta:
db_table = 'categorias'
ordering = ['nombre']
verbose_name = 'Categoría'
verbose_name_plural = 'Categorías'
class Autor(models.Model):
"""Autores de libros"""
nombre = models.CharField(max_length=200)
apellido = models.CharField(max_length=200)
biografia = models.TextField(blank=True)
fecha_nacimiento = models.DateField(null=True, blank=True)
pais = models.CharField(max_length=100, blank=True)
foto = models.ImageField(upload_to='autores/', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
@property
def nombre_completo(self):
return f"{self.nombre} {self.apellido}"
def __str__(self):
return self.nombre_completo
class Meta:
db_table = 'autores'
ordering = ['apellido', 'nombre']
verbose_name = 'Autor'
verbose_name_plural = 'Autores'
class Libro(models.Model):
"""Libros en la biblioteca"""
titulo = models.CharField(max_length=300)
isbn = models.CharField(max_length=13, unique=True)
autor = models.ForeignKey(Autor, on_delete=models.CASCADE, related_name='libros')
categoria = models.ForeignKey(Categoria, on_delete=models.SET_NULL, null=True, related_name='libros')
editorial = models.CharField(max_length=200)
fecha_publicacion = models.DateField()
numero_paginas = models.IntegerField(validators=[MinValueValidator(1)])
idioma = models.CharField(max_length=50, default='Español')
descripcion = models.TextField()
portada = models.ImageField(upload_to='libros/portadas/', null=True, blank=True)
# Control de inventario
cantidad_total = models.IntegerField(default=1, validators=[MinValueValidator(0)])
cantidad_disponible = models.IntegerField(default=1, validators=[MinValueValidator(0)])
# Información adicional
precio = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
ubicacion_fisica = models.CharField(max_length=100, blank=True, help_text="Ej: Estante A-3")
# Metadata
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='libros_creados')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_active = models.BooleanField(default=True)
@property
def disponible(self):
return self.cantidad_disponible > 0
def __str__(self):
return f"{self.titulo} - {self.autor.nombre_completo}"
class Meta:
db_table = 'libros'
ordering = ['-created_at']
verbose_name = 'Libro'
verbose_name_plural = 'Libros'
class Prestamo(models.Model):
"""Préstamos de libros a usuarios"""
ESTADOS = [
('activo', 'Activo'),
('devuelto', 'Devuelto'),
('vencido', 'Vencido'),
]
usuario = models.ForeignKey(User, on_delete=models.CASCADE, related_name='prestamos')
libro = models.ForeignKey(Libro, on_delete=models.CASCADE, related_name='prestamos')
fecha_prestamo = models.DateTimeField(auto_now_add=True)
fecha_devolucion_estimada = models.DateField()
fecha_devolucion_real = models.DateTimeField(null=True, blank=True)
estado = models.CharField(max_length=20, choices=ESTADOS, default='activo')
notas = models.TextField(blank=True)
# Multas
multa = models.DecimalField(max_digits=10, decimal_places=2, default=0)
def __str__(self):
return f"{self.usuario.username} - {self.libro.titulo}"
class Meta:
db_table = 'prestamos'
ordering = ['-fecha_prestamo']
verbose_name = 'Préstamo'
verbose_name_plural = 'Préstamos'
class Resena(models.Model):
"""Reseñas de libros por usuarios"""
libro = models.ForeignKey(Libro, on_delete=models.CASCADE, related_name='resenas')
usuario = models.ForeignKey(User, on_delete=models.CASCADE, related_name='resenas')
calificacion = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(5)])
comentario = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.usuario.username} - {self.libro.titulo} ({self.calificacion}★)"
class Meta:
db_table = 'resenas'
ordering = ['-created_at']
unique_together = ['libro', 'usuario']
verbose_name = 'Reseña'
verbose_name_plural = 'Reseñas'
class ChatMessage(models.Model):
"""Mensajes del chat en tiempo real"""
room_name = models.CharField(max_length=255)
user = models.ForeignKey(User, on_delete=models.CASCADE)
message = models.TextField()
timestamp = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'{self.user.username}: {self.message[:50]}'
class Meta:
db_table = 'chat_messages'
ordering = ['timestamp']
class OAuthToken(models.Model):
"""Tokens de OAuth para autenticación externa"""
user = models.ForeignKey(User, on_delete=models.CASCADE)
provider = models.CharField(max_length=50) # 'google' o 'github'
access_token = models.TextField()
refresh_token = models.TextField(blank=True, null=True)
token_type = models.CharField(max_length=50)
expires_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'{self.user.username} - {self.provider}'
class Meta:
db_table = 'oauth_tokens'
unique_together = ['user', 'provider']
serializers.py
SERIALIZERS
📍 libros/serializers.py
from rest_framework import serializers
from django.contrib.auth.models import User
from .models import (
UserProfile, Categoria, Autor, Libro,
Prestamo, Resena, ChatMessage, OAuthToken
)
class UserSerializer(serializers.ModelSerializer):
"""Serializer para el modelo User de Django"""
class Meta:
model = User
fields = ['id', 'username', 'email', 'first_name', 'last_name', 'date_joined']
read_only_fields = ['id', 'date_joined']
class UserProfileSerializer(serializers.ModelSerializer):
"""Serializer para el perfil extendido del usuario"""
user = UserSerializer(read_only=True)
class Meta:
model = UserProfile
fields = [
'id', 'user', 'bio', 'avatar', 'phone', 'birth_date',
'google_id', 'github_id', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
class RegisterSerializer(serializers.ModelSerializer):
"""Serializer para registro de nuevos usuarios"""
password = serializers.CharField(
write_only=True,
required=True,
style={'input_type': 'password'},
min_length=8
)
password2 = serializers.CharField(
write_only=True,
required=True,
style={'input_type': 'password'}
)
class Meta:
model = User
fields = ['username', 'email', 'password', 'password2', 'first_name', 'last_name']
def validate(self, attrs):
if attrs['password'] != attrs['password2']:
raise serializers.ValidationError({"password": "Las contraseñas no coinciden"})
if User.objects.filter(email=attrs['email']).exists():
raise serializers.ValidationError({"email": "Este email ya está registrado"})
return attrs
def create(self, validated_data):
validated_data.pop('password2')
user = User.objects.create_user(**validated_data)
UserProfile.objects.create(user=user)
return user
class CategoriaSerializer(serializers.ModelSerializer):
"""Serializer para categorías de libros"""
total_libros = serializers.SerializerMethodField()
class Meta:
model = Categoria
fields = ['id', 'nombre', 'descripcion', 'created_at', 'total_libros']
read_only_fields = ['id', 'created_at']
def get_total_libros(self, obj):
return obj.libros.count()
class AutorSerializer(serializers.ModelSerializer):
"""Serializer para autores"""
nombre_completo = serializers.ReadOnlyField()
total_libros = serializers.SerializerMethodField()
class Meta:
model = Autor
fields = [
'id', 'nombre', 'apellido', 'nombre_completo', 'biografia',
'fecha_nacimiento', 'pais', 'foto', 'created_at', 'total_libros'
]
read_only_fields = ['id', 'created_at']
def get_total_libros(self, obj):
return obj.libros.count()
class LibroSerializer(serializers.ModelSerializer):
"""Serializer completo para libros"""
autor = AutorSerializer(read_only=True)
autor_id = serializers.PrimaryKeyRelatedField(
queryset=Autor.objects.all(),
source='autor',
write_only=True
)
categoria = CategoriaSerializer(read_only=True)
categoria_id = serializers.PrimaryKeyRelatedField(
queryset=Categoria.objects.all(),
source='categoria',
write_only=True,
allow_null=True
)
created_by = UserSerializer(read_only=True)
disponible = serializers.ReadOnlyField()
promedio_calificacion = serializers.SerializerMethodField()
class Meta:
model = Libro
fields = [
'id', 'titulo', 'isbn', 'autor', 'autor_id', 'categoria', 'categoria_id',
'editorial', 'fecha_publicacion', 'numero_paginas', 'idioma', 'descripcion',
'portada', 'cantidad_total', 'cantidad_disponible', 'precio',
'ubicacion_fisica', 'created_by', 'created_at', 'updated_at',
'is_active', 'disponible', 'promedio_calificacion'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'created_by']
def get_promedio_calificacion(self, obj):
resenas = obj.resenas.all()
if resenas.exists():
return round(sum(r.calificacion for r in resenas) / resenas.count(), 1)
return None
class LibroListSerializer(serializers.ModelSerializer):
"""Serializer simplificado para listado de libros"""
autor_nombre = serializers.CharField(source='autor.nombre_completo', read_only=True)
categoria_nombre = serializers.CharField(source='categoria.nombre', read_only=True)
disponible = serializers.ReadOnlyField()
class Meta:
model = Libro
fields = [
'id', 'titulo', 'isbn', 'autor_nombre', 'categoria_nombre',
'editorial', 'portada', 'cantidad_disponible', 'disponible'
]
class PrestamoSerializer(serializers.ModelSerializer):
"""Serializer para préstamos de libros"""
usuario = UserSerializer(read_only=True)
libro = LibroSerializer(read_only=True)
libro_id = serializers.PrimaryKeyRelatedField(
queryset=Libro.objects.all(),
source='libro',
write_only=True
)
dias_restantes = serializers.SerializerMethodField()
class Meta:
model = Prestamo
fields = [
'id', 'usuario', 'libro', 'libro_id', 'fecha_prestamo',
'fecha_devolucion_estimada', 'fecha_devolucion_real',
'estado', 'notas', 'multa', 'dias_restantes'
]
read_only_fields = ['id', 'fecha_prestamo', 'usuario']
def get_dias_restantes(self, obj):
if obj.estado == 'devuelto':
return 0
from datetime import date
delta = obj.fecha_devolucion_estimada - date.today()
return delta.days
def validate_libro_id(self, libro):
if not libro.disponible:
raise serializers.ValidationError("Este libro no está disponible")
return libro
def create(self, validated_data):
# Reducir cantidad disponible
libro = validated_data['libro']
libro.cantidad_disponible -= 1
libro.save()
return super().create(validated_data)
class ResenaSerializer(serializers.ModelSerializer):
"""Serializer para reseñas de libros"""
usuario = UserSerializer(read_only=True)
libro_titulo = serializers.CharField(source='libro.titulo', read_only=True)
class Meta:
model = Resena
fields = [
'id', 'libro', 'libro_titulo', 'usuario', 'calificacion',
'comentario', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'usuario', 'created_at', 'updated_at']
def validate_calificacion(self, value):
if value < 1 or value > 5:
raise serializers.ValidationError("La calificación debe estar entre 1 y 5")
return value
class ChatMessageSerializer(serializers.ModelSerializer):
"""Serializer para mensajes de chat"""
username = serializers.CharField(source='user.username', read_only=True)
class Meta:
model = ChatMessage
fields = ['id', 'room_name', 'user', 'username', 'message', 'timestamp']
read_only_fields = ['id', 'user', 'timestamp']
class OAuthTokenSerializer(serializers.ModelSerializer):
"""Serializer para tokens OAuth"""
class Meta:
model = OAuthToken
fields = ['id', 'provider', 'access_token', 'token_type', 'expires_at', 'created_at']
read_only_fields = ['id', 'created_at']
extra_kwargs = {
'access_token': {'write_only': True},
'refresh_token': {'write_only': True}
}
views.py
VISTAS & API
📍 libros/views.py
from rest_framework import viewsets, status, generics, filters
from rest_framework.decorators import api_view, permission_classes, action
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken
from django.contrib.auth.models import User
from django.contrib.auth import authenticate
from django.shortcuts import render, redirect
from django.views.decorators.csrf import csrf_exempt
from django.db.models import Q, Avg
from datetime import datetime, timedelta, date
from decouple import config
import requests
from .models import (
UserProfile, Categoria, Autor, Libro,
Prestamo, Resena, ChatMessage, OAuthToken
)
from .serializers import (
UserSerializer, UserProfileSerializer, RegisterSerializer,
CategoriaSerializer, AutorSerializer, LibroSerializer, LibroListSerializer,
PrestamoSerializer, ResenaSerializer, ChatMessageSerializer, OAuthTokenSerializer
)
# ============= VISTAS DE TEMPLATES =============
def index(request):
"""Página principal"""
return render(request, 'index.html')
def login_view(request):
"""Página de login"""
return render(request, 'auth/login.html')
def register_view(request):
"""Página de registro"""
return render(request, 'auth/register.html')
def dashboard_view(request):
"""Dashboard principal (requiere autenticación)"""
return render(request, 'dashboard.html')
def chat_view(request):
"""Página de chat en tiempo real"""
return render(request, 'chat.html')
def libros_view(request):
"""Página de catálogo de libros"""
return render(request, 'libros/catalogo.html')
def prestamos_view(request):
"""Página de préstamos del usuario"""
return render(request, 'prestamos/mis_prestamos.html')
# ============= AUTENTICACIÓN =============
@api_view(['POST'])
@permission_classes([AllowAny])
def register(request):
"""Registrar un nuevo usuario"""
serializer = RegisterSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
refresh = RefreshToken.for_user(user)
return Response({
'user': UserSerializer(user).data,
'tokens': {
'refresh': str(refresh),
'access': str(refresh.access_token),
},
'message': 'Usuario registrado exitosamente'
}, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@api_view(['POST'])
@permission_classes([AllowAny])
def login(request):
"""Login con username/email y password"""
username = request.data.get('username')
password = request.data.get('password')
if not username or not password:
return Response({
'error': 'Username y password son requeridos'
}, status=status.HTTP_400_BAD_REQUEST)
# Intentar login con username
user = authenticate(username=username, password=password)
# Si falla, intentar con email
if not user:
try:
user_obj = User.objects.get(email=username)
user = authenticate(username=user_obj.username, password=password)
except User.DoesNotExist:
pass
if user:
refresh = RefreshToken.for_user(user)
return Response({
'user': UserSerializer(user).data,
'tokens': {
'refresh': str(refresh),
'access': str(refresh.access_token),
},
'message': 'Login exitoso'
})
return Response({
'error': 'Credenciales inválidas'
}, status=status.HTTP_401_UNAUTHORIZED)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def logout(request):
"""Cerrar sesión (blacklist del token)"""
try:
refresh_token = request.data.get('refresh')
token = RefreshToken(refresh_token)
token.blacklist()
return Response({
'message': 'Logout exitoso'
}, status=status.HTTP_200_OK)
except Exception as e:
return Response({
'error': str(e)
}, status=status.HTTP_400_BAD_REQUEST)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def profile(request):
"""Obtener perfil del usuario autenticado"""
try:
profile = request.user.profile
return Response(UserProfileSerializer(profile).data)
except UserProfile.DoesNotExist:
# Crear perfil si no existe
profile = UserProfile.objects.create(user=request.user)
return Response(UserProfileSerializer(profile).data)
# ============= OAUTH 2.0 =============
@api_view(['POST'])
@permission_classes([AllowAny])
def google_login(request):
"""Autenticación con Google OAuth"""
token = request.data.get('access_token')
if not token:
return Response({'error': 'Token requerido'}, status=status.HTTP_400_BAD_REQUEST)
try:
# Verificar token con Google
response = requests.get(
f'https://www.googleapis.com/oauth2/v1/userinfo?access_token={token}'
)
if response.status_code != 200:
return Response({'error': 'Token inválido'}, status=status.HTTP_401_UNAUTHORIZED)
user_data = response.json()
email = user_data.get('email')
google_id = user_data.get('id')
# Buscar o crear usuario
user, created = User.objects.get_or_create(
email=email,
defaults={
'username': email.split('@')[0],
'first_name': user_data.get('given_name', ''),
'last_name': user_data.get('family_name', ''),
}
)
# Actualizar o crear perfil
profile, _ = UserProfile.objects.get_or_create(user=user)
profile.google_id = google_id
profile.save()
# Guardar token OAuth
OAuthToken.objects.update_or_create(
user=user,
provider='google',
defaults={
'access_token': token,
'token_type': 'Bearer',
}
)
# Generar JWT
refresh = RefreshToken.for_user(user)
return Response({
'user': UserSerializer(user).data,
'tokens': {
'refresh': str(refresh),
'access': str(refresh.access_token),
},
'is_new_user': created
})
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# ============= VIEWSETS DE LIBROS =============
class CategoriaViewSet(viewsets.ModelViewSet):
"""CRUD de categorías"""
queryset = Categoria.objects.all()
serializer_class = CategoriaSerializer
permission_classes = [IsAuthenticated]
@action(detail=True, methods=['get'])
def libros(self, request, pk=None):
"""Obtener todos los libros de una categoría"""
categoria = self.get_object()
libros = categoria.libros.filter(is_active=True)
serializer = LibroListSerializer(libros, many=True)
return Response(serializer.data)
class AutorViewSet(viewsets.ModelViewSet):
"""CRUD de autores"""
queryset = Autor.objects.all()
serializer_class = AutorSerializer
permission_classes = [IsAuthenticated]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ['nombre', 'apellido', 'pais']
ordering_fields = ['apellido', 'created_at']
@action(detail=True, methods=['get'])
def libros(self, request, pk=None):
"""Obtener todos los libros de un autor"""
autor = self.get_object()
libros = autor.libros.filter(is_active=True)
serializer = LibroListSerializer(libros, many=True)
return Response(serializer.data)
class LibroViewSet(viewsets.ModelViewSet):
"""CRUD de libros con búsqueda avanzada"""
queryset = Libro.objects.filter(is_active=True).select_related('autor', 'categoria')
permission_classes = [IsAuthenticated]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ['titulo', 'isbn', 'autor__nombre', 'autor__apellido', 'editorial']
ordering_fields = ['titulo', 'fecha_publicacion', 'created_at']
def get_serializer_class(self):
if self.action == 'list':
return LibroListSerializer
return LibroSerializer
def perform_create(self, serializer):
serializer.save(created_by=self.request.user)
@action(detail=True, methods=['get'])
def resenas(self, request, pk=None):
"""Obtener reseñas de un libro"""
libro = self.get_object()
resenas = libro.resenas.all()
serializer = ResenaSerializer(resenas, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def disponibles(self, request):
"""Obtener solo libros disponibles"""
libros = self.queryset.filter(cantidad_disponible__gt=0)
serializer = self.get_serializer(libros, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def buscar(self, request):
"""Búsqueda avanzada de libros"""
queryset = self.queryset
# Filtros
titulo = request.query_params.get('titulo')
autor = request.query_params.get('autor')
categoria = request.query_params.get('categoria')
isbn = request.query_params.get('isbn')
disponible = request.query_params.get('disponible')
if titulo:
queryset = queryset.filter(titulo__icontains=titulo)
if autor:
queryset = queryset.filter(
Q(autor__nombre__icontains=autor) |
Q(autor__apellido__icontains=autor)
)
if categoria:
queryset = queryset.filter(categoria__nombre__icontains=categoria)
if isbn:
queryset = queryset.filter(isbn=isbn)
if disponible == 'true':
queryset = queryset.filter(cantidad_disponible__gt=0)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class PrestamoViewSet(viewsets.ModelViewSet):
"""CRUD de préstamos"""
serializer_class = PrestamoSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
# Usuarios normales solo ven sus préstamos
if not self.request.user.is_staff:
return Prestamo.objects.filter(usuario=self.request.user)
# Staff ve todos
return Prestamo.objects.all()
def perform_create(self, serializer):
serializer.save(usuario=self.request.user)
@action(detail=True, methods=['post'])
def devolver(self, request, pk=None):
"""Marcar préstamo como devuelto"""
prestamo = self.get_object()
if prestamo.usuario != request.user and not request.user.is_staff:
return Response(
{'error': 'No tienes permiso para devolver este préstamo'},
status=status.HTTP_403_FORBIDDEN
)
if prestamo.estado == 'devuelto':
return Response(
{'error': 'Este préstamo ya fue devuelto'},
status=status.HTTP_400_BAD_REQUEST
)
# Actualizar préstamo
prestamo.fecha_devolucion_real = datetime.now()
prestamo.estado = 'devuelto'
# Calcular multa si está vencido
if date.today() > prestamo.fecha_devolucion_estimada:
dias_retraso = (date.today() - prestamo.fecha_devolucion_estimada).days
prestamo.multa = dias_retraso * 10 # $10 por día
prestamo.save()
# Incrementar cantidad disponible del libro
libro = prestamo.libro
libro.cantidad_disponible += 1
libro.save()
return Response(PrestamoSerializer(prestamo).data)
@action(detail=False, methods=['get'])
def activos(self, request):
"""Obtener préstamos activos del usuario"""
prestamos = self.get_queryset().filter(estado='activo')
serializer = self.get_serializer(prestamos, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def vencidos(self, request):
"""Obtener préstamos vencidos"""
prestamos = self.get_queryset().filter(
estado='activo',
fecha_devolucion_estimada__lt=date.today()
)
for prestamo in prestamos:
prestamo.estado = 'vencido'
prestamo.save()
serializer = self.get_serializer(prestamos, many=True)
return Response(serializer.data)
class ResenaViewSet(viewsets.ModelViewSet):
"""CRUD de reseñas"""
queryset = Resena.objects.all()
serializer_class = ResenaSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
queryset = super().get_queryset()
libro_id = self.request.query_params.get('libro')
if libro_id:
queryset = queryset.filter(libro_id=libro_id)
return queryset
def perform_create(self, serializer):
serializer.save(usuario=self.request.user)
class ChatMessageViewSet(viewsets.ReadOnlyModelViewSet):
"""Mensajes de chat (solo lectura, se crean vía WebSocket)"""
queryset = ChatMessage.objects.all()
serializer_class = ChatMessageSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
room = self.request.query_params.get('room')
if room:
return ChatMessage.objects.filter(room_name=room)
return ChatMessage.objects.all()
# ============= INTEGRACIÓN GOOGLE BOOKS API =============
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def buscar_google_books(request):
"""Buscar libros en Google Books API"""
query = request.query_params.get('q')
if not query:
return Response({'error': 'Parámetro "q" requerido'}, status=status.HTTP_400_BAD_REQUEST)
try:
response = requests.get(
'https://www.googleapis.com/books/v1/volumes',
params={'q': query, 'maxResults': 10}
)
if response.status_code == 200:
data = response.json()
# Formatear resultados
libros = []
for item in data.get('items', []):
volume_info = item.get('volumeInfo', {})
libros.append({
'titulo': volume_info.get('title'),
'autores': volume_info.get('authors', []),
'editorial': volume_info.get('publisher'),
'fecha_publicacion': volume_info.get('publishedDate'),
'descripcion': volume_info.get('description'),
'isbn': next((id['identifier'] for id in volume_info.get('industryIdentifiers', [])
if id['type'] == 'ISBN_13'), None),
'paginas': volume_info.get('pageCount'),
'idioma': volume_info.get('language'),
'portada': volume_info.get('imageLinks', {}).get('thumbnail'),
})
return Response({'resultados': libros})
else:
return Response({'error': 'Error al consultar Google Books'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def importar_de_google_books(request):
"""Importar un libro desde Google Books"""
if not request.user.is_staff:
return Response({'error': 'Solo administradores pueden importar libros'},
status=status.HTTP_403_FORBIDDEN)
datos = request.data
try:
# Buscar o crear autor
nombre_autor = datos.get('autores', ['Desconocido'])[0]
partes = nombre_autor.split()
nombre = partes[0] if partes else 'Desconocido'
apellido = ' '.join(partes[1:]) if len(partes) > 1 else ''
autor, _ = Autor.objects.get_or_create(
nombre=nombre,
apellido=apellido
)
# Crear libro
libro = Libro.objects.create(
titulo=datos.get('titulo'),
isbn=datos.get('isbn', ''),
autor=autor,
editorial=datos.get('editorial', ''),
fecha_publicacion=datos.get('fecha_publicacion', date.today()),
numero_paginas=datos.get('paginas', 0),
idioma=datos.get('idioma', 'es'),
descripcion=datos.get('descripcion', ''),
created_by=request.user
)
return Response(LibroSerializer(libro).data, status=status.HTTP_201_CREATED)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
def home_view(request):
"""Vista principal"""
return render(request, 'base.html')
def login_view(request):
"""Vista de login"""
return render(request, 'login.html')
def register_view(request):
"""Vista de registro"""
return render(request, 'register.html')
def dashboard_view(request):
"""Vista de dashboard"""
return render(request, 'dashboard.html')
def chat_view(request, room_name='general'):
"""Vista de chat WebSocket"""
return render(request, 'chat.html', {'room_name': room_name})
def oauth_connect_view(request):
"""Vista para conectar OAuth"""
return render(request, 'oauth_connect.html')
# ============= API ENDPOINTS =============
@api_view(['POST'])
@permission_classes([AllowAny])
def register_api(request):
"""Registro de nuevos usuarios"""
serializer = RegisterSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
refresh = RefreshToken.for_user(user)
return Response({
'message': 'Usuario creado exitosamente',
'user': UserSerializer(user).data,
'tokens': {
'refresh': str(refresh),
'access': str(refresh.access_token),
}
}, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@api_view(['POST'])
@permission_classes([AllowAny])
def login_api(request):
"""Login con JWT"""
username = request.data.get('username')
password = request.data.get('password')
user = authenticate(username=username, password=password)
if user is not None:
refresh = RefreshToken.for_user(user)
return Response({
'message': 'Login exitoso',
'user': UserSerializer(user).data,
'tokens': {
'refresh': str(refresh),
'access': str(refresh.access_token),
}
})
return Response({'error': 'Credenciales inválidas'}, status=status.HTTP_401_UNAUTHORIZED)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def logout_api(request):
"""Logout - Invalidar token"""
try:
refresh_token = request.data.get('refresh')
token = RefreshToken(refresh_token)
token.blacklist()
return Response({'message': 'Logout exitoso'})
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def profile_api(request):
"""Obtener perfil del usuario autenticado"""
profile = UserProfile.objects.get(user=request.user)
return Response(UserProfileSerializer(profile).data)
# ============= OAUTH 2.0 ENDPOINTS =============
@api_view(['GET'])
@permission_classes([AllowAny])
def google_login(request):
"""Iniciar OAuth con Google"""
client_id = config('GOOGLE_OAUTH_CLIENT_ID')
redirect_uri = config('GOOGLE_OAUTH_REDIRECT_URI')
scope = 'openid email profile'
auth_url = (
f'https://accounts.google.com/o/oauth2/v2/auth?'
f'client_id={client_id}&'
f'redirect_uri={redirect_uri}&'
f'response_type=code&'
f'scope={scope}'
)
return redirect(auth_url)
@api_view(['GET'])
@permission_classes([AllowAny])
def google_callback(request):
"""Callback de Google OAuth"""
code = request.GET.get('code')
if not code:
return Response({'error': 'No se recibió código'}, status=status.HTTP_400_BAD_REQUEST)
# Intercambiar código por token
token_url = 'https://oauth2.googleapis.com/token'
token_data = {
'code': code,
'client_id': config('GOOGLE_OAUTH_CLIENT_ID'),
'client_secret': config('GOOGLE_OAUTH_CLIENT_SECRET'),
'redirect_uri': config('GOOGLE_OAUTH_REDIRECT_URI'),
'grant_type': 'authorization_code'
}
token_response = requests.post(token_url, data=token_data)
token_json = token_response.json()
access_token = token_json.get('access_token')
# Obtener información del usuario
user_info_url = 'https://www.googleapis.com/oauth2/v2/userinfo'
headers = {'Authorization': f'Bearer {access_token}'}
user_info_response = requests.get(user_info_url, headers=headers)
user_info = user_info_response.json()
# Crear o actualizar usuario
email = user_info.get('email')
google_id = user_info.get('id')
user, created = User.objects.get_or_create(
email=email,
defaults={
'username': email.split('@')[0],
'first_name': user_info.get('given_name', ''),
'last_name': user_info.get('family_name', '')
}
)
# Actualizar perfil
profile, _ = UserProfile.objects.get_or_create(user=user)
profile.google_id = google_id
profile.save()
# Guardar token OAuth
OAuthToken.objects.update_or_create(
user=user,
provider='google',
defaults={
'access_token': access_token,
'refresh_token': token_json.get('refresh_token', ''),
'token_type': token_json.get('token_type', 'Bearer'),
'expires_at': datetime.now() + timedelta(seconds=token_json.get('expires_in', 3600))
}
)
# Generar JWT para la aplicación
refresh = RefreshToken.for_user(user)
return Response({
'message': 'Login con Google exitoso',
'user': UserSerializer(user).data,
'tokens': {
'refresh': str(refresh),
'access': str(refresh.access_token),
}
})
@api_view(['GET'])
@permission_classes([AllowAny])
def github_login(request):
"""Iniciar OAuth con GitHub"""
client_id = config('GITHUB_OAUTH_CLIENT_ID')
redirect_uri = config('GITHUB_OAUTH_REDIRECT_URI')
scope = 'user:email'
auth_url = (
f'https://github.com/login/oauth/authorize?'
f'client_id={client_id}&'
f'redirect_uri={redirect_uri}&'
f'scope={scope}'
)
return redirect(auth_url)
@api_view(['GET'])
@permission_classes([AllowAny])
def github_callback(request):
"""Callback de GitHub OAuth"""
code = request.GET.get('code')
if not code:
return Response({'error': 'No se recibió código'}, status=status.HTTP_400_BAD_REQUEST)
# Intercambiar código por token
token_url = 'https://github.com/login/oauth/access_token'
token_data = {
'client_id': config('GITHUB_OAUTH_CLIENT_ID'),
'client_secret': config('GITHUB_OAUTH_CLIENT_SECRET'),
'code': code,
'redirect_uri': config('GITHUB_OAUTH_REDIRECT_URI')
}
headers = {'Accept': 'application/json'}
token_response = requests.post(token_url, data=token_data, headers=headers)
token_json = token_response.json()
access_token = token_json.get('access_token')
# Obtener información del usuario
user_info_url = 'https://api.github.com/user'
headers = {'Authorization': f'token {access_token}'}
user_info_response = requests.get(user_info_url, headers=headers)
user_info = user_info_response.json()
# Obtener email
email_url = 'https://api.github.com/user/emails'
email_response = requests.get(email_url, headers=headers)
emails = email_response.json()
primary_email = next((e['email'] for e in emails if e['primary']), None)
github_id = str(user_info.get('id'))
username = user_info.get('login')
# Crear o actualizar usuario
user, created = User.objects.get_or_create(
username=username,
defaults={
'email': primary_email or f'{username}@github.com',
'first_name': user_info.get('name', '').split()[0] if user_info.get('name') else ''
}
)
# Actualizar perfil
profile, _ = UserProfile.objects.get_or_create(user=user)
profile.github_id = github_id
profile.save()
# Guardar token OAuth
OAuthToken.objects.update_or_create(
user=user,
provider='github',
defaults={
'access_token': access_token,
'token_type': token_json.get('token_type', 'bearer')
}
)
# Generar JWT
refresh = RefreshToken.for_user(user)
return Response({
'message': 'Login con GitHub exitoso',
'user': UserSerializer(user).data,
'tokens': {
'refresh': str(refresh),
'access': str(refresh.access_token),
}
})
urls.py (Libros App)
RUTAS API
📍 libros/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import TokenRefreshView
from . import views
# Router para ViewSets
router = DefaultRouter()
router.register('categorias', views.CategoriaViewSet, basename='categoria')
router.register('autores', views.AutorViewSet, basename='autor')
router.register('libros', views.LibroViewSet, basename='libro')
router.register('prestamos', views.PrestamoViewSet, basename='prestamo')
router.register('resenas', views.ResenaViewSet, basename='resena')
router.register('messages', views.ChatMessageViewSet, basename='message')
urlpatterns = [
# ============= VISTAS DE TEMPLATES =============
path('', views.index, name='index'),
path('login/', views.login_view, name='login'),
path('register/', views.register_view, name='register'),
path('dashboard/', views.dashboard_view, name='dashboard'),
path('chat/', views.chat_view, name='chat'),
path('libros/', views.libros_view, name='libros'),
path('prestamos/', views.prestamos_view, name='prestamos'),
# ============= AUTENTICACIÓN API =============
path('api/auth/register/', views.register, name='api_register'),
path('api/auth/login/', views.login, name='api_login'),
path('api/auth/logout/', views.logout, name='api_logout'),
path('api/auth/profile/', views.profile, name='api_profile'),
path('api/auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
# ============= OAUTH 2.0 =============
path('api/auth/google/', views.google_login, name='google_login'),
# ============= GOOGLE BOOKS API =============
path('api/google-books/buscar/', views.buscar_google_books, name='buscar_google_books'),
path('api/google-books/importar/', views.importar_de_google_books, name='importar_google_books'),
# ============= ROUTER API (CRUD) =============
path('api/', include(router.urls)),
]
consumers.py
WEBSOCKETS
📍 libros/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.contrib.auth.models import User
from .models import ChatMessage
class ChatConsumer(AsyncWebsocketConsumer):
"""Consumer para chat en tiempo real"""
async def connect(self):
self.room_name = self.scope['url_route']['kwargs'].get('room_name', 'general')
self.room_group_name = f'chat_{self.room_name}'
# Unirse al grupo de la sala
await self.channel_layer.group_add(
self.room_group_name,
self.channel_name
)
await self.accept()
# Enviar mensaje de bienvenida
await self.send(text_data=json.dumps({
'type': 'connection_established',
'message': f'Conectado al chat {self.room_name}'
}))
async def disconnect(self, close_code):
# Leave room group
await self.channel_layer.group_discard(
self.room_group_name,
self.channel_name
)
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message = text_data_json.get('message')
username = text_data_json.get('username', 'Anonymous')
# Guardar mensaje en base de datos
await self.save_message(username, message)
# Send message to room group
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'chat_message',
'message': message,
'username': username
}
)
async def chat_message(self, event):
message = event['message']
username = event['username']
# Send message to WebSocket
await self.send(text_data=json.dumps({
'type': 'chat_message',
'message': message,
'username': username
}))
@database_sync_to_async
def save_message(self, username, message):
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
user = None
if user:
ChatMessage.objects.create(
room_name=self.room_name,
user=user,
message=message
)
routing.py
WEBSOCKET ROUTING
📍 libros/routing.py
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r'ws/chat/(?P<room_name>\w+)/$', consumers.ChatConsumer.as_asgi()),
re_path(r'ws/notificaciones/$', consumers.NotificacionConsumer.as_asgi()),
]
admin.py
DJANGO ADMIN
📍 libros/admin.py
from django.contrib import admin
from .models import (
UserProfile, Categoria, Autor, Libro,
Prestamo, Resena, ChatMessage, OAuthToken
)
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ['user', 'phone', 'created_at']
search_fields = ['user__username', 'user__email']
list_filter = ['created_at']
@admin.register(Categoria)
class CategoriaAdmin(admin.ModelAdmin):
list_display = ['nombre', 'created_at']
search_fields = ['nombre', 'descripcion']
@admin.register(Autor)
class AutorAdmin(admin.ModelAdmin):
list_display = ['nombre_completo', 'pais', 'created_at']
search_fields = ['nombre', 'apellido', 'pais']
list_filter = ['pais', 'created_at']
@admin.register(Libro)
class LibroAdmin(admin.ModelAdmin):
list_display = ['titulo', 'autor', 'categoria', 'isbn', 'cantidad_disponible', 'is_active']
list_filter = ['categoria', 'is_active', 'created_at']
search_fields = ['titulo', 'isbn', 'autor__nombre', 'autor__apellido']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Información Básica', {
'fields': ('titulo', 'autor', 'categoria', 'isbn')
}),
('Detalles', {
'fields': ('editorial', 'fecha_publicacion', 'numero_paginas', 'idioma', 'descripcion')
}),
('Inventario', {
'fields': ('cantidad_total', 'cantidad_disponible', 'ubicacion_fisica', 'precio')
}),
('Imagen', {
'fields': ('portada',)
}),
('Metadata', {
'fields': ('is_active', 'created_by', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
@admin.register(Prestamo)
class PrestamoAdmin(admin.ModelAdmin):
list_display = ['usuario', 'libro', 'fecha_prestamo', 'fecha_devolucion_estimada', 'estado', 'multa']
list_filter = ['estado', 'fecha_prestamo', 'fecha_devolucion_estimada']
search_fields = ['usuario__username', 'libro__titulo']
readonly_fields = ['fecha_prestamo']
actions = ['marcar_como_devuelto']
def marcar_como_devuelto(self, request, queryset):
from datetime import datetime
for prestamo in queryset:
if prestamo.estado != 'devuelto':
prestamo.fecha_devolucion_real = datetime.now()
prestamo.estado = 'devuelto'
prestamo.libro.cantidad_disponible += 1
prestamo.libro.save()
prestamo.save()
self.message_user(request, f"{queryset.count()} préstamos marcados como devueltos")
marcar_como_devuelto.short_description = "Marcar seleccionados como devueltos"
@admin.register(Resena)
class ResenaAdmin(admin.ModelAdmin):
list_display = ['libro', 'usuario', 'calificacion', 'created_at']
list_filter = ['calificacion', 'created_at']
search_fields = ['libro__titulo', 'usuario__username', 'comentario']
@admin.register(ChatMessage)
class ChatMessageAdmin(admin.ModelAdmin):
list_display = ['user', 'room_name', 'message', 'timestamp']
list_filter = ['room_name', 'timestamp']
search_fields = ['user__username', 'message']
@admin.register(OAuthToken)
class OAuthTokenAdmin(admin.ModelAdmin):
list_display = ['user', 'provider', 'created_at']
list_filter = ['provider']
search_fields = ['user__username']
🌐 Templates HTML Completos
base.html
TEMPLATE BASE
📍 templates/base.html
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Django REST + JWT + OAuth{% endblock %}</title>
<link rel="stylesheet" href="{% static 'css/style.css' %}">
{% block extra_css %}{% endblock %}
</head>
<body>
<nav class="navbar">
<div class="container">
<a href="/" class="logo">🚀 Mi Proyecto</a>
<ul class="nav-menu">
<li><a href="/">Inicio</a></li>
<li id="nav-dashboard"><a href="/dashboard/">Dashboard</a></li>
<li id="nav-chat"><a href="/chat/general/">Chat</a></li>
<li id="nav-login"><a href="/login/">Login</a></li>
<li id="nav-register"><a href="/register/">Registro</a></li>
<li id="nav-logout"><a href="#" onclick="logout()">Logout</a></li>
</ul>
</div>
</nav>
<main>
{% block content %}
<div class="hero">
<h1>Bienvenido a la Aplicación</h1>
<p>Django REST + JWT + OAuth + WebSockets</p>
</div>
{% endblock %}
</main>
<footer>
<p>© 2024 Mi Proyecto Django</p>
</footer>
<script>
// Actualizar navegación según autenticación
const token = localStorage.getItem('access_token');
if (token) {
document.getElementById('nav-login').style.display = 'none';
document.getElementById('nav-register').style.display = 'none';
} else {
document.getElementById('nav-dashboard').style.display = 'none';
document.getElementById('nav-chat').style.display = 'none';
document.getElementById('nav-logout').style.display = 'none';
}
function logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login/';
}
</script>
{% block extra_js %}{% endblock %}
</body>
</html>
index.html
PÁGINA INICIO
📍 templates/index.html
{% extends 'base.html' %}
{% load static %}
{% block title %}Inicio - Sistema de Biblioteca Digital{% endblock %}
{% block content %}
<div class="hero">
<div class="container">
<h1>📚 Sistema de Biblioteca Digital</h1>
<p>Gestión completa de préstamos y catálogo de libros</p>
<p style="margin-top: 30px;">
<a href="/login/" class="btn btn-primary" style="display: inline-block; margin-right: 10px;">Iniciar Sesión</a>
<a href="/register/" class="btn btn-primary" style="display: inline-block; background: #764ba2;">Registrarse</a>
</p>
</div>
</div>
<div class="container" style="margin-top: 60px;">
<div class="dashboard-grid">
<div class="card">
<h3>🔐 Autenticación JWT</h3>
<p>Sistema seguro de autenticación con JSON Web Tokens</p>
<ul>
<li>Access tokens de corta duración</li>
<li>Refresh tokens para renovación</li>
<li>Autenticación stateless</li>
</ul>
</div>
<div class="card">
<h3>🔗 OAuth 2.0</h3>
<p>Login con servicios externos populares</p>
<ul>
<li>Integración con Google</li>
<li>Integración con GitHub</li>
<li>Flujo OAuth completo</li>
</ul>
</div>
<div class="card">
<h3>💬 WebSockets</h3>
<p>Comunicación en tiempo real con Django Channels</p>
<ul>
<li>Chat en tiempo real</li>
<li>Notificaciones push</li>
<li>Conexión bidireccional</li>
</ul>
</div>
<div class="card">
<h3>📊 GraphQL</h3>
<p>API flexible con consultas personalizadas</p>
<ul>
<li>Queries optimizadas</li>
<li>Mutations para modificar datos</li>
<li>Sin over-fetching</li>
</ul>
</div>
<div class="card">
<h3>🛡️ Seguridad</h3>
<p>Protección avanzada contra ataques</p>
<ul>
<li>Rate limiting</li>
<li>Validaciones anti-XSS y SQL Injection</li>
<li>CORS y CSRF configurados</li>
</ul>
</div>
<div class="card">
<h3>📚 Gestión de Biblioteca</h3>
<p>Sistema completo de biblioteca digital</p>
<ul>
<li>Catálogo de libros</li>
<li>Sistema de préstamos</li>
<li>Reseñas y calificaciones</li>
</ul>
</div>
</div>
</div>
<div class="container" style="margin-top: 60px; text-align: center; color: white;">
<h2>🚀 Tecnologías Utilizadas</h2>
<div style="margin-top: 30px; display: flex; justify-content: center; gap: 20px; flex-wrap: wrap;">
<div style="background: rgba(255,255,255,0.1); padding: 15px 25px; border-radius: 10px;">Django 4.2</div>
<div style="background: rgba(255,255,255,0.1); padding: 15px 25px; border-radius: 10px;">Django REST Framework</div>
<div style="background: rgba(255,255,255,0.1); padding: 15px 25px; border-radius: 10px;">SimpleJWT</div>
<div style="background: rgba(255,255,255,0.1); padding: 15px 25px; border-radius: 10px;">OAuth Toolkit</div>
<div style="background: rgba(255,255,255,0.1); padding: 15px 25px; border-radius: 10px;">Django Channels</div>
<div style="background: rgba(255,255,255,0.1); padding: 15px 25px; border-radius: 10px;">Graphene-Django</div>
<div style="background: rgba(255,255,255,0.1); padding: 15px 25px; border-radius: 10px;">MySQL</div>
<div style="background: rgba(255,255,255,0.1); padding: 15px 25px; border-radius: 10px;">Redis</div>
<div style="background: rgba(255,255,255,0.1); padding: 15px 25px; border-radius: 10px;">Docker</div>
</div>
</div>
{% endblock %}
login.html
LOGIN
📍 templates/login.html
{% extends 'base.html' %}
{% load static %}
{% block title %}Login - JWT{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<h2>🔐 Iniciar Sesión</h2>
<form id="loginForm">
<div class="form-group">
<label for="username">Usuario:</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Contraseña:</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Iniciar Sesión</button>
</form>
<div class="divider">O continuar con</div>
<div class="oauth-buttons">
<a href="/api/auth/google/" class="btn btn-google">
<img src="https://www.google.com/favicon.ico" alt="Google">
Google
</a>
<a href="/api/auth/github/" class="btn btn-github">
<img src="https://github.com/favicon.ico" alt="GitHub">
GitHub
</a>
</div>
<p class="text-center">
¿No tienes cuenta? <a href="/register/">Regístrate aquí</a>
</p>
<div id="message" class="message"></div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const messageDiv = document.getElementById('message');
try {
const response = await fetch('/api/login/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
// Guardar tokens
localStorage.setItem('access_token', data.tokens.access);
localStorage.setItem('refresh_token', data.tokens.refresh);
localStorage.setItem('username', data.user.username);
messageDiv.className = 'message success';
messageDiv.textContent = '✅ Login exitoso! Redirigiendo...';
setTimeout(() => {
window.location.href = '/dashboard/';
}, 1000);
} else {
messageDiv.className = 'message error';
messageDiv.textContent = '❌ ' + (data.error || 'Error al iniciar sesión');
}
} catch (error) {
messageDiv.className = 'message error';
messageDiv.textContent = '❌ Error de conexión: ' + error.message;
}
});
</script>
{% endblock %}
register.html
REGISTRO
📍 templates/register.html
{% extends 'base.html' %}
{% load static %}
{% block title %}Registro{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<h2>📝 Crear Cuenta</h2>
<form id="registerForm">
<div class="form-group">
<label for="username">Usuario:</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="password">Contraseña:</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="password2">Confirmar Contraseña:</label>
<input type="password" id="password2" name="password2" required>
</div>
<button type="submit" class="btn btn-primary">Registrarse</button>
</form>
<p class="text-center">
¿Ya tienes cuenta? <a href="/login/">Inicia sesión</a>
</p>
<div id="message" class="message"></div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {
username: document.getElementById('username').value,
email: document.getElementById('email').value,
password: document.getElementById('password').value,
password2: document.getElementById('password2').value
};
const messageDiv = document.getElementById('message');
try {
const response = await fetch('/api/register/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (response.ok) {
localStorage.setItem('access_token', data.tokens.access);
localStorage.setItem('refresh_token', data.tokens.refresh);
messageDiv.className = 'message success';
messageDiv.textContent = '✅ Registro exitoso! Redirigiendo...';
setTimeout(() => {
window.location.href = '/dashboard/';
}, 1000);
} else {
messageDiv.className = 'message error';
messageDiv.textContent = '❌ ' + JSON.stringify(data);
}
} catch (error) {
messageDiv.className = 'message error';
messageDiv.textContent = '❌ Error: ' + error.message;
}
});
</script>
{% endblock %}
dashboard.html
DASHBOARD
📍 templates/dashboard.html
{% extends 'base.html' %}
{% load static %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="dashboard-container">
<h1>📊 Dashboard</h1>
<p>Bienvenido, <span id="username"></span></p>
<div class="dashboard-grid">
<div class="card">
<h3>👤 Perfil</h3>
<div id="profile-info"></div>
</div>
<div class="card">
<h3>🔑 OAuth Conectado</h3>
<div id="oauth-status"></div>
</div>
<div class="card">
<h3>📦 Productos</h3>
<div id="products-list"></div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const token = localStorage.getItem('access_token');
if (!token) {
window.location.href = '/login/';
}
// Mostrar nombre de usuario
document.getElementById('username').textContent = localStorage.getItem('username') || 'Usuario';
// Cargar perfil
async function loadProfile() {
try {
const response = await fetch('/api/profile/', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
document.getElementById('profile-info').innerHTML = `
<p>Email: ${data.user.email}</p>
<p>Fecha de registro: ${new Date(data.user.date_joined).toLocaleDateString()}</p>
`;
}
} catch (error) {
console.error('Error:', error);
}
}
// Cargar productos
async function loadProducts() {
try {
const response = await fetch('/api/products/', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
const productsList = document.getElementById('products-list');
if (data.results && data.results.length > 0) {
productsList.innerHTML = data.results.map(p =>
`<div>${p.name} - $${p.price}</div>`
).join('');
} else {
productsList.innerHTML = '<p>No hay productos</p>';
}
}
} catch (error) {
console.error('Error:', error);
}
}
loadProfile();
loadProducts();
</script>
{% endblock %}
chat.html
CHAT WEBSOCKET
📍 templates/chat.html
{% extends 'base.html' %}
{% load static %}
{% block title %}Chat - {{ room_name }}{% endblock %}
{% block content %}
<div class="chat-container">
<h1>💬 Chat: {{ room_name }}</h1>
<div class="chat-box">
<div id="chat-log" class="chat-messages"></div>
<div class="chat-input">
<input type="text" id="chat-message-input" placeholder="Escribe un mensaje...">
<button id="chat-message-submit">Enviar</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const roomName = "{{ room_name }}";
const username = localStorage.getItem('username') || 'Anonymous';
// WebSocket connection
const chatSocket = new WebSocket(
'ws://' + window.location.host + '/ws/chat/' + roomName + '/'
);
chatSocket.onopen = function(e) {
console.log('WebSocket conectado');
addSystemMessage('Conectado al chat');
};
chatSocket.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'chat_message') {
addMessage(data.username, data.message);
} else if (data.type === 'connection_established') {
addSystemMessage(data.message);
}
};
chatSocket.onclose = function(e) {
console.error('WebSocket cerrado');
addSystemMessage('Desconectado del chat');
};
chatSocket.onerror = function(e) {
console.error('WebSocket error:', e);
};
// Enviar mensaje
document.getElementById('chat-message-submit').onclick = function() {
sendMessage();
};
document.getElementById('chat-message-input').onkeyup = function(e) {
if (e.keyCode === 13) {
sendMessage();
}
};
function sendMessage() {
const messageInput = document.getElementById('chat-message-input');
const message = messageInput.value.trim();
if (message) {
chatSocket.send(JSON.stringify({
'message': message,
'username': username
}));
messageInput.value = '';
}
}
function addMessage(username, message) {
const chatLog = document.getElementById('chat-log');
const messageElement = document.createElement('div');
messageElement.className = 'chat-message';
messageElement.innerHTML = `
<strong>${username}:</strong> ${message}
<span class="time">${new Date().toLocaleTimeString()}</span>
`;
chatLog.appendChild(messageElement);
chatLog.scrollTop = chatLog.scrollHeight;
}
function addSystemMessage(message) {
const chatLog = document.getElementById('chat-log');
const messageElement = document.createElement('div');
messageElement.className = 'system-message';
messageElement.textContent = message;
chatLog.appendChild(messageElement);
}
</script>
{% endblock %}
oauth_connect.html
OAUTH
📍 templates/oauth_connect.html
{% extends 'base.html' %}
{% load static %}
{% block title %}Conectar OAuth{% endblock %}
{% block content %}
<div class="oauth-container">
<h1>🔗 Conectar Servicios OAuth</h1>
<div class="oauth-cards">
<div class="oauth-card">
<h2>Google</h2>
<p>Conecta tu cuenta de Google</p>
<a href="/api/auth/google/" class="btn btn-google">Conectar Google</a>
</div>
<div class="oauth-card">
<h2>GitHub</h2>
<p>Conecta tu cuenta de GitHub</p>
<a href="/api/auth/github/" class="btn btn-github">Conectar GitHub</a>
</div>
</div>
</div>
{% endblock %}
libros_list.html
CATÁLOGO
📍 templates/libros_list.html
{% extends 'base.html' %}
{% load static %}
{% block title %}Catálogo de Libros{% endblock %}
{% block content %}
<div class="dashboard-container">
<h1>📚 Catálogo de Libros</h1>
<div style="margin-bottom: 30px;">
<input type="text" id="search-input" placeholder="Buscar libros por título, autor o ISBN..."
style="width: 100%; max-width: 600px; padding: 12px; border: 2px solid #e1e8ed; border-radius: 8px;">
</div>
<div id="libros-grid" class="dashboard-grid">
<!-- Los libros se cargarán aquí dinámicamente -->
</div>
<div id="pagination" style="text-align: center; margin-top: 30px;">
<!-- Paginación -->
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const token = localStorage.getItem('access_token');
let currentPage = 1;
// Cargar libros
async function loadLibros(page = 1, search = '') {
try {
let url = `/api/libros/?page=${page}`;
if (search) {
url += `&search=${search}`;
}
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
displayLibros(data.results);
displayPagination(data);
}
} catch (error) {
console.error('Error:', error);
}
}
// Mostrar libros
function displayLibros(libros) {
const grid = document.getElementById('libros-grid');
if (libros.length === 0) {
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center;">No se encontraron libros</p>';
return;
}
grid.innerHTML = libros.map(libro => `
<div class="card">
<h3>${libro.titulo}</h3>
<p><strong>Autor:</strong> ${libro.autor?.nombre || 'N/A'}</p>
<p><strong>ISBN:</strong> ${libro.isbn}</p>
<p><strong>Categoría:</strong> ${libro.categoria?.nombre || 'N/A'}</p>
<p><strong>Precio:</strong> $${libro.precio}</p>
<p><strong>Disponibles:</strong> ${libro.cantidad_disponible}</p>
<p><strong>Publicación:</strong> ${libro.fecha_publicacion}</p>
${libro.disponible ?
'<button onclick="prestarLibro(' + libro.id + ')" class="btn btn-primary">Prestar</button>' :
'<button class="btn" disabled>No disponible</button>'
}
</div>
`).join('');
}
// Mostrar paginación
function displayPagination(data) {
const pagination = document.getElementById('pagination');
let html = '';
if (data.previous) {
html += '<button onclick="loadLibros(' + (currentPage - 1) + ')" class="btn">Anterior</button> ';
}
html += `<span>Página ${currentPage}</span> `;
if (data.next) {
html += '<button onclick="loadLibros(' + (currentPage + 1) + ')" class="btn">Siguiente</button>';
}
pagination.innerHTML = html;
}
// Prestar libro
async function prestarLibro(libroId) {
if (!confirm('¿Deseas solicitar el préstamo de este libro?')) {
return;
}
try {
const response = await fetch('/api/prestamos/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
libro_id: libroId
})
});
if (response.ok) {
alert('Préstamo solicitado exitosamente');
loadLibros(currentPage);
} else {
const error = await response.json();
alert('Error: ' + (error.detail || 'No se pudo procesar el préstamo'));
}
} catch (error) {
console.error('Error:', error);
alert('Error al solicitar préstamo');
}
}
// Búsqueda
let searchTimeout;
document.getElementById('search-input').addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentPage = 1;
loadLibros(1, e.target.value);
}, 500);
});
// Cargar al inicio
loadLibros();
</script>
{% endblock %}
libro_detail.html
DETALLE LIBRO
📍 templates/libro_detail.html
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ libro.titulo }} - Detalle{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="card" style="max-width: 800px; margin: 0 auto;">
<div style="display: flex; gap: 30px;">
<div style="flex: 1;">
<img src="{{ libro.imagen_portada|default:'/static/img/libro-default.png' }}"
alt="{{ libro.titulo }}"
style="width: 100%; border-radius: 10px;">
</div>
<div style="flex: 2;">
<h1 style="color: #667eea; margin-bottom: 20px;">{{ libro.titulo }}</h1>
<div style="margin-bottom: 15px;">
<strong>📖 Autor:</strong> {{ libro.autor.nombre }}
</div>
<div style="margin-bottom: 15px;">
<strong>🏷️ Categoría:</strong> {{ libro.categoria.nombre }}
</div>
<div style="margin-bottom: 15px;">
<strong>📘 ISBN:</strong> {{ libro.isbn }}
</div>
<div style="margin-bottom: 15px;">
<strong>💰 Precio:</strong> ${{ libro.precio }}
</div>
<div style="margin-bottom: 15px;">
<strong>📅 Publicación:</strong> {{ libro.fecha_publicacion }}
</div>
<div style="margin-bottom: 15px;">
<strong>📦 Editorial:</strong> {{ libro.editorial }}
</div>
<div style="margin-bottom: 15px;">
<strong>📄 Páginas:</strong> {{ libro.numero_paginas }}
</div>
<div style="margin-bottom: 15px;">
<strong>💿 Stock:</strong> {{ libro.cantidad_disponible }} / {{ libro.cantidad_total }}
</div>
<div style="margin-bottom: 20px;">
<strong>⭐ Calificación:</strong> {{ libro.calificacion_promedio|floatformat:1 }} / 5.0
</div>
<div style="margin-bottom: 20px;">
<p>{{ libro.descripcion }}</p>
</div>
{% if libro.disponible %}
<button onclick="prestarLibro({{ libro.id }})" class="btn btn-primary">
Solicitar Préstamo
</button>
{% else %}
<button class="btn" disabled>No Disponible</button>
{% endif %}
</div>
</div>
</div>
<!-- Reseñas -->
<div class="card" style="max-width: 800px; margin: 30px auto;">
<h2>💬 Reseñas</h2>
<div id="resenas-list">
<!-- Reseñas se cargarán aquí -->
</div>
<div style="margin-top: 30px;">
<h3>Escribe una reseña</h3>
<form id="resena-form">
<div class="form-group">
<label>Calificación:</label>
<select id="calificacion" required>
<option value="5">⭐⭐⭐⭐⭐ (5)</option>
<option value="4">⭐⭐⭐⭐ (4)</option>
<option value="3">⭐⭐⭐ (3)</option>
<option value="2">⭐⭐ (2)</option>
<option value="1">⭐ (1)</option>
</select>
</div>
<div class="form-group">
<label>Comentario:</label>
<textarea id="comentario" rows="4" style="width: 100%; padding: 10px;" required></textarea>
</div>
<button type="submit" class="btn btn-primary">Publicar Reseña</button>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const token = localStorage.getItem('access_token');
const libroId = {{ libro.id }};
// Prestar libro
async function prestarLibro(id) {
if (!confirm('¿Deseas solicitar el préstamo de este libro?')) {
return;
}
try {
const response = await fetch('/api/prestamos/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ libro_id: id })
});
if (response.ok) {
alert('Préstamo solicitado exitosamente');
location.reload();
}
} catch (error) {
console.error('Error:', error);
}
}
// Cargar reseñas
async function loadResenas() {
try {
const response = await fetch(`/api/libros/${libroId}/resenas/`);
const data = await response.json();
const list = document.getElementById('resenas-list');
list.innerHTML = data.map(r => `
<div style="border-bottom: 1px solid #eee; padding: 15px 0;">
<strong>${r.usuario.username}</strong> - ${'⭐'.repeat(r.calificacion)}
<p>${r.comentario}</p>
<small style="color: #999;">${new Date(r.created_at).toLocaleDateString()}</small>
</div>
`).join('');
} catch (error) {
console.error('Error:', error);
}
}
// Enviar reseña
document.getElementById('resena-form').addEventListener('submit', async (e) => {
e.preventDefault();
const calificacion = document.getElementById('calificacion').value;
const comentario = document.getElementById('comentario').value;
try {
const response = await fetch('/api/resenas/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
libro: libroId,
calificacion: parseInt(calificacion),
comentario: comentario
})
});
if (response.ok) {
alert('Reseña publicada');
document.getElementById('comentario').value = '';
loadResenas();
}
} catch (error) {
console.error('Error:', error);
}
});
loadResenas();
</script>
{% endblock %}
🎨 Archivos CSS & JavaScript
style.css
CSS PRINCIPAL
📍 static/css/style.css
/* ========================================
ESTILOS PRINCIPALES DEL PROYECTO
======================================== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* NAVBAR */
.navbar {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 1rem 0;
position: sticky;
top: 0;
z-index: 1000;
}
.navbar .container {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
color: #667eea;
text-decoration: none;
}
.nav-menu {
display: flex;
list-style: none;
gap: 2rem;
}
.nav-menu a {
color: #333;
text-decoration: none;
font-weight: 500;
transition: color 0.3s;
}
.nav-menu a:hover {
color: #667eea;
}
/* HERO */
.hero {
text-align: center;
padding: 100px 20px;
color: white;
}
.hero h1 {
font-size: 3rem;
margin-bottom: 1rem;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.hero p {
font-size: 1.5rem;
}
/* AUTH CONTAINER */
.auth-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
padding: 20px;
}
.auth-card {
background: white;
border-radius: 20px;
padding: 40px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
max-width: 450px;
width: 100%;
}
.auth-card h2 {
text-align: center;
color: #667eea;
margin-bottom: 30px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #555;
}
.form-group input {
width: 100%;
padding: 12px;
border: 2px solid #e1e8ed;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.btn {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
.btn-google {
background: #4285f4;
color: white;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 10px;
}
.btn-github {
background: #333;
color: white;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.divider {
text-align: center;
margin: 20px 0;
color: #999;
position: relative;
}
.divider::before,
.divider::after {
content: '';
position: absolute;
top: 50%;
width: 40%;
height: 1px;
background: #ddd;
}
.divider::before { left: 0; }
.divider::after { right: 0; }
.text-center {
text-align: center;
margin-top: 20px;
}
.message {
margin-top: 20px;
padding: 12px;
border-radius: 8px;
text-align: center;
display: none;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
display: block;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
display: block;
}
/* DASHBOARD */
.dashboard-container {
max-width: 1200px;
margin: 40px auto;
padding: 20px;
}
.dashboard-container h1 {
color: white;
margin-bottom: 30px;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.card {
background: white;
border-radius: 12px;
padding: 25px;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.card h3 {
color: #667eea;
margin-bottom: 15px;
}
/* CHAT */
.chat-container {
max-width: 800px;
margin: 40px auto;
padding: 20px;
}
.chat-container h1 {
color: white;
margin-bottom: 20px;
}
.chat-box {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.chat-messages {
height: 400px;
overflow-y: auto;
border: 1px solid #e1e8ed;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
}
.chat-message {
margin-bottom: 10px;
padding: 8px;
background: #f7f9fa;
border-radius: 8px;
}
.system-message {
text-align: center;
color: #999;
font-style: italic;
margin: 10px 0;
}
.chat-input {
display: flex;
gap: 10px;
}
.chat-input input {
flex: 1;
padding: 12px;
border: 2px solid #e1e8ed;
border-radius: 8px;
font-size: 1rem;
}
.chat-input button {
padding: 12px 24px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
/* FOOTER */
footer {
text-align: center;
padding: 20px;
color: white;
margin-top: 40px;
}
auth.js
AUTENTICACIÓN JS
📍 static/js/auth.js
// ========================================
// FUNCIONES DE AUTENTICACIÓN JWT
// ========================================
const API_URL = window.location.origin;
// Obtener token
function getAccessToken() {
return localStorage.getItem('access_token');
}
function getRefreshToken() {
return localStorage.getItem('refresh_token');
}
// Guardar tokens
function saveTokens(access, refresh) {
localStorage.setItem('access_token', access);
localStorage.setItem('refresh_token', refresh);
}
// Limpiar tokens
function clearTokens() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('username');
}
// Verificar si está autenticado
function isAuthenticated() {
return !!getAccessToken();
}
// Refrescar token
async function refreshAccessToken() {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token');
}
try {
const response = await fetch(`${API_URL}/api/token/refresh/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refresh: refreshToken })
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('access_token', data.access);
return data.access;
} else {
clearTokens();
window.location.href = '/login/';
}
} catch (error) {
console.error('Error refreshing token:', error);
clearTokens();
window.location.href = '/login/';
}
}
// Fetch con autenticación
async function authenticatedFetch(url, options = {}) {
let token = getAccessToken();
if (!token) {
window.location.href = '/login/';
return;
}
options.headers = {
...options.headers,
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
};
let response = await fetch(url, options);
// Si el token expiró, intentar refrescarlo
if (response.status === 401) {
token = await refreshAccessToken();
options.headers['Authorization'] = `Bearer ${token}`;
response = await fetch(url, options);
}
return response;
}
// Logout
async function logout() {
const refreshToken = getRefreshToken();
if (refreshToken) {
try {
await authenticatedFetch(`${API_URL}/api/logout/`, {
method: 'POST',
body: JSON.stringify({ refresh: refreshToken })
});
} catch (error) {
console.error('Logout error:', error);
}
}
clearTokens();
window.location.href = '/login/';
}
// Proteger páginas (llamar en páginas que requieren autenticación)
function requireAuth() {
if (!isAuthenticated()) {
window.location.href = '/login/';
}
}
websocket.js
WEBSOCKETS JS
📍 static/js/websocket.js
// ========================================
// CLIENTE WEBSOCKET
// ========================================
class ChatWebSocket {
constructor(roomName, onMessageCallback) {
this.roomName = roomName;
this.onMessageCallback = onMessageCallback;
this.socket = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.connect();
}
connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/chat/${this.roomName}/`;
this.socket = new WebSocket(wsUrl);
this.socket.onopen = (e) => {
console.log('WebSocket conectado');
this.reconnectAttempts = 0;
this.onConnectionChange(true);
};
this.socket.onmessage = (e) => {
const data = JSON.parse(e.data);
if (this.onMessageCallback) {
this.onMessageCallback(data);
}
};
this.socket.onclose = (e) => {
console.log('WebSocket cerrado');
this.onConnectionChange(false);
this.attemptReconnect();
};
this.socket.onerror = (e) => {
console.error('WebSocket error:', e);
};
}
attemptReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
console.log(`Intentando reconectar... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
setTimeout(() => {
this.connect();
}, 3000 * this.reconnectAttempts);
} else {
console.error('Máximo de intentos de reconexión alcanzado');
}
}
sendMessage(message, username) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({
message: message,
username: username
}));
return true;
} else {
console.error('WebSocket no está conectado');
return false;
}
}
close() {
if (this.socket) {
this.socket.close();
}
}
onConnectionChange(isConnected) {
console.log('Connection status:', isConnected ? 'Connected' : 'Disconnected');
}
}
oauth.js
OAUTH JS
📍 static/js/oauth.js
// ========================================
// FUNCIONES OAUTH 2.0
// ========================================
// Iniciar flujo OAuth con Google
function loginWithGoogle() {
window.location.href = '/api/auth/google/';
}
// Iniciar flujo OAuth con GitHub
function loginWithGitHub() {
window.location.href = '/api/auth/github/';
}
// Manejar callback de OAuth (llamar en página de callback)
function handleOAuthCallback() {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
const error = urlParams.get('error');
if (error) {
console.error('OAuth error:', error);
alert('Error en autenticación OAuth: ' + error);
window.location.href = '/login/';
return;
}
if (token) {
localStorage.setItem('access_token', token);
// Obtener información del usuario
fetch('/api/profile/', {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(response => response.json())
.then(data => {
localStorage.setItem('username', data.user.username);
window.location.href = '/dashboard/';
})
.catch(error => {
console.error('Error:', error);
window.location.href = '/login/';
});
}
}
// Verificar estado de conexiones OAuth
async function checkOAuthConnections() {
const token = localStorage.getItem('access_token');
if (!token) return;
try {
const response = await fetch('/api/profile/', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
return {
google: !!data.google_id,
github: !!data.github_id
};
}
} catch (error) {
console.error('Error checking OAuth:', error);
}
return { google: false, github: false };
}
dashboard.js
DASHBOARD JS
📍 static/js/dashboard.js
// ========================================
// FUNCIONES DEL DASHBOARD
// ========================================
const API_URL = window.location.origin;
// Cargar estadísticas del dashboard
async function loadDashboardStats() {
const token = localStorage.getItem('access_token');
if (!token) {
window.location.href = '/login/';
return;
}
try {
// Cargar estadísticas de usuario
const statsResponse = await authenticatedFetch(`${API_URL}/api/dashboard/stats/`);
if (statsResponse && statsResponse.ok) {
const stats = await statsResponse.json();
displayStats(stats);
}
// Cargar actividad reciente
const activityResponse = await authenticatedFetch(`${API_URL}/api/dashboard/activity/`);
if (activityResponse && activityResponse.ok) {
const activity = await activityResponse.json();
displayActivity(activity);
}
// Cargar préstamos activos
const prestamosResponse = await authenticatedFetch(`${API_URL}/api/prestamos/?estado=activo`);
if (prestamosResponse && prestamosResponse.ok) {
const prestamos = await prestamosResponse.json();
displayPrestamosActivos(prestamos.results);
}
} catch (error) {
console.error('Error cargando dashboard:', error);
}
}
// Mostrar estadísticas
function displayStats(stats) {
const statsContainer = document.getElementById('stats-container');
if (!statsContainer) return;
statsContainer.innerHTML = `
<div class="stat-card">
<h3>📚 Libros Prestados</h3>
<p class="stat-number">${stats.total_prestamos || 0}</p>
</div>
<div class="stat-card">
<h3>📖 Libros Activos</h3>
<p class="stat-number">${stats.prestamos_activos || 0}</p>
</div>
<div class="stat-card">
<h3>⭐ Reseñas Escritas</h3>
<p class="stat-number">${stats.total_resenas || 0}</p>
</div>
<div class="stat-card">
<h3>💰 Multas Pendientes</h3>
<p class="stat-number">$${stats.multas_pendientes || 0}</p>
</div>
`;
}
// Mostrar actividad reciente
function displayActivity(activities) {
const activityContainer = document.getElementById('activity-container');
if (!activityContainer) return;
if (!activities || activities.length === 0) {
activityContainer.innerHTML = '<p>No hay actividad reciente</p>';
return;
}
activityContainer.innerHTML = activities.map(activity => `
<div class="activity-item">
<span class="activity-icon">${getActivityIcon(activity.type)}</span>
<div>
<strong>${activity.title}</strong>
<p>${activity.description}</p>
<small>${formatDate(activity.created_at)}</small>
</div>
</div>
`).join('');
}
// Mostrar préstamos activos
function displayPrestamosActivos(prestamos) {
const prestamosContainer = document.getElementById('prestamos-activos');
if (!prestamosContainer) return;
if (!prestamos || prestamos.length === 0) {
prestamosContainer.innerHTML = '<p>No tienes préstamos activos</p>';
return;
}
prestamosContainer.innerHTML = prestamos.map(prestamo => {
const diasRestantes = calcularDiasRestantes(prestamo.fecha_devolucion_estimada);
const isVencido = diasRestantes < 0;
return `
<div class="prestamo-card ${isVencido ? 'vencido' : ''}">
<h4>${prestamo.libro.titulo}</h4>
<p><strong>Autor:</strong> ${prestamo.libro.autor.nombre}</p>
<p><strong>Fecha de préstamo:</strong> ${formatDate(prestamo.fecha_prestamo)}</p>
<p><strong>Fecha de devolución:</strong> ${formatDate(prestamo.fecha_devolucion_estimada)}</p>
<p class="${isVencido ? 'text-danger' : 'text-success'}">
<strong>${isVencido ? '⚠️ Vencido' : '✅ Activo'}</strong> -
${Math.abs(diasRestantes)} día(s) ${isVencido ? 'de retraso' : 'restantes'}
</p>
${prestamo.multa > 0 ? `<p class="text-danger">💰 Multa: $${prestamo.multa}</p>` : ''}
<button onclick="devolverLibro(${prestamo.id})" class="btn btn-primary">
Marcar como Devuelto
</button>
</div>
`;
}).join('');
}
// Devolver libro
async function devolverLibro(prestamoId) {
if (!confirm('¿Confirmas la devolución de este libro?')) {
return;
}
try {
const response = await authenticatedFetch(`${API_URL}/api/prestamos/${prestamoId}/devolver/`, {
method: 'POST'
});
if (response && response.ok) {
alert('Libro devuelto exitosamente');
loadDashboardStats();
} else {
const error = await response.json();
alert('Error: ' + (error.detail || 'No se pudo devolver el libro'));
}
} catch (error) {
console.error('Error:', error);
alert('Error al devolver el libro');
}
}
// Utilidades
function getActivityIcon(type) {
const icons = {
'prestamo': '📚',
'devolucion': '✅',
'resena': '⭐',
'multa': '💰',
'registro': '👤'
};
return icons[type] || '📌';
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('es-MX', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
function calcularDiasRestantes(fechaDevolucion) {
const hoy = new Date();
const fecha = new Date(fechaDevolucion);
const diffTime = fecha - hoy;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}
// Auto-inicializar en dashboard
if (window.location.pathname === '/dashboard/') {
document.addEventListener('DOMContentLoaded', loadDashboardStats);
}
notifications.js
NOTIFICACIONES JS
📍 static/js/notifications.js
// ========================================
// SISTEMA DE NOTIFICACIONES EN TIEMPO REAL
// ========================================
class NotificationManager {
constructor() {
this.socket = null;
this.connected = false;
this.notifications = [];
this.initWebSocket();
}
initWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/notifications/`;
this.socket = new WebSocket(wsUrl);
this.socket.onopen = () => {
console.log('✅ Notificaciones conectadas');
this.connected = true;
this.updateConnectionStatus(true);
};
this.socket.onmessage = (e) => {
const data = JSON.parse(e.data);
this.handleNotification(data);
};
this.socket.onclose = () => {
console.log('❌ Notificaciones desconectadas');
this.connected = false;
this.updateConnectionStatus(false);
// Intentar reconectar después de 5 segundos
setTimeout(() => this.initWebSocket(), 5000);
};
this.socket.onerror = (error) => {
console.error('Error en WebSocket de notificaciones:', error);
};
}
handleNotification(data) {
console.log('📬 Nueva notificación:', data);
this.notifications.unshift(data);
// Mostrar notificación en pantalla
this.showNotification(data);
// Actualizar contador de notificaciones
this.updateNotificationBadge();
// Guardar en localStorage (opcional)
this.saveNotifications();
}
showNotification(notification) {
// Crear elemento de notificación
const notifElement = document.createElement('div');
notifElement.className = 'notification-toast ' + (notification.type || 'info');
notifElement.innerHTML = `
<div class="notification-content">
<strong>${this.getNotificationIcon(notification.type)} ${notification.title}</strong>
<p>${notification.message}</p>
</div>
<button onclick="this.parentElement.remove()">×</button>
`;
// Agregar al contenedor
let container = document.getElementById('notification-container');
if (!container) {
container = document.createElement('div');
container.id = 'notification-container';
container.style.cssText = `
position: fixed;
top: 80px;
right: 20px;
z-index: 9999;
max-width: 350px;
`;
document.body.appendChild(container);
}
container.appendChild(notifElement);
// Auto-remover después de 5 segundos
setTimeout(() => {
notifElement.style.opacity = '0';
setTimeout(() => notifElement.remove(), 300);
}, 5000);
// Reproducir sonido (opcional)
this.playNotificationSound();
}
getNotificationIcon(type) {
const icons = {
'prestamo': '📚',
'devolucion': '✅',
'vencimiento': '⚠️',
'multa': '💰',
'nuevo_libro': '📖',
'resena': '⭐',
'info': 'ℹ️',
'success': '✅',
'warning': '⚠️',
'error': '❌'
};
return icons[type] || '📌';
}
updateNotificationBadge() {
const badge = document.getElementById('notification-badge');
if (badge) {
const unreadCount = this.notifications.filter(n => !n.read).length;
badge.textContent = unreadCount;
badge.style.display = unreadCount > 0 ? 'inline-block' : 'none';
}
}
updateConnectionStatus(isConnected) {
const statusElement = document.getElementById('ws-status');
if (statusElement) {
statusElement.textContent = isConnected ? '🟢 Conectado' : '🔴 Desconectado';
statusElement.className = isConnected ? 'status-connected' : 'status-disconnected';
}
}
markAsRead(notificationId) {
const notification = this.notifications.find(n => n.id === notificationId);
if (notification) {
notification.read = true;
this.updateNotificationBadge();
this.saveNotifications();
}
}
markAllAsRead() {
this.notifications.forEach(n => n.read = true);
this.updateNotificationBadge();
this.saveNotifications();
}
saveNotifications() {
localStorage.setItem('notifications', JSON.stringify(this.notifications.slice(0, 50)));
}
loadNotifications() {
const saved = localStorage.getItem('notifications');
if (saved) {
this.notifications = JSON.parse(saved);
this.updateNotificationBadge();
}
}
playNotificationSound() {
// Opcional: reproducir sonido de notificación
const audio = new Audio('/static/sounds/notification.mp3');
audio.volume = 0.3;
audio.play().catch(e => {
// Ignorar errores de auto-play
});
}
}
// Inicializar gestor de notificaciones
let notificationManager;
document.addEventListener('DOMContentLoaded', () => {
const token = localStorage.getItem('access_token');
if (token) {
notificationManager = new NotificationManager();
notificationManager.loadNotifications();
}
});
// API pública
function getNotifications() {
return notificationManager ? notificationManager.notifications : [];
}
function markNotificationAsRead(id) {
if (notificationManager) {
notificationManager.markAsRead(id);
}
}
function markAllNotificationsAsRead() {
if (notificationManager) {
notificationManager.markAllAsRead();
}
}
🐳 Docker y Deployment
Dockerfile
DOCKER
📍 Dockerfile
# ========================================
# DOCKERFILE PARA PRODUCCIÓN
# ========================================
FROM python:3.11-slim
# Variables de entorno
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
# Directorio de trabajo
WORKDIR /app
# Instalar dependencias del sistema
RUN apt-get update && apt-get install -y \
gcc \
default-libmysqlclient-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# Copiar requirements y instalar
COPY requirements.txt .
RUN pip install --upgrade pip && \
pip install -r requirements.txt
# Copiar proyecto
COPY . .
# Colectar archivos estáticos
RUN python manage.py collectstatic --noinput
# Exponer puerto
EXPOSE 8000
# Comando de inicio
CMD ["daphne", "-b", "0.0.0.0", "-p", "8000", "biblioteca_digital.asgi:application"]
docker-compose.yml
DOCKER COMPOSE
📍 docker-compose.yml
version: '3.8'
services:
# Base de datos MySQL
db:
image: mysql:8.0
container_name: django_mysql
restart: always
environment:
MYSQL_DATABASE: mi_base_datos
MYSQL_ROOT_PASSWORD: root_password
MYSQL_USER: django_user
MYSQL_PASSWORD: django_password
volumes:
- mysql_data:/var/lib/mysql
ports:
- "3306:3306"
networks:
- django_network
# Redis para WebSockets
redis:
image: redis:7-alpine
container_name: django_redis
restart: always
ports:
- "6379:6379"
networks:
- django_network
# Aplicación Django
web:
build: .
container_name: django_app
restart: always
command: daphne -b 0.0.0.0 -p 8000 biblioteca_digital.asgi:application
volumes:
- .:/app
- static_volume:/app/staticfiles
- media_volume:/app/media
ports:
- "8000:8000"
env_file:
- .env
depends_on:
- db
- redis
networks:
- django_network
# Nginx (opcional para producción)
nginx:
image: nginx:alpine
container_name: django_nginx
restart: always
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- static_volume:/app/staticfiles
- media_volume:/app/media
depends_on:
- web
networks:
- django_network
volumes:
mysql_data:
static_volume:
media_volume:
networks:
django_network:
driver: bridge
.dockerignore
DOCKER IGNORE
📍 .dockerignore
# Git .git .gitignore # Python __pycache__ *.py[cod] *$py.class *.so .Python env/ venv/ ENV/ # Django *.log db.sqlite3 media/ staticfiles/ # IDE .vscode/ .idea/ *.swp *.swo # OS .DS_Store Thumbs.db # Otros *.md .env.example
nginx.conf
NGINX
📍 nginx.conf
# ========================================
# CONFIGURACIÓN NGINX PARA PRODUCCIÓN
# ========================================
events {
worker_connections 1024;
}
http {
upstream django_app {
server web:8000;
}
server {
listen 80;
server_name localhost;
client_max_body_size 100M;
# Servir archivos estáticos
location /static/ {
alias /app/staticfiles/;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Servir archivos media
location /media/ {
alias /app/media/;
expires 7d;
add_header Cache-Control "public";
}
# WebSocket para Django Channels
location /ws/ {
proxy_pass http://django_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
proxy_read_timeout 86400;
}
# Proxy para aplicación Django
location / {
proxy_pass http://django_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
}
# Headers de seguridad
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
}
# Configuración SSL (descomentar para HTTPS)
# server {
# listen 443 ssl http2;
# server_name your-domain.com;
#
# ssl_certificate /etc/nginx/ssl/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/key.pem;
#
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
#
# # Resto de la configuración igual que arriba
# }
}
gunicorn_start.sh
GUNICORN SCRIPT
📍 gunicorn_start.sh
#!/bin/bash
# ========================================
# SCRIPT DE INICIO GUNICORN PARA PRODUCCIÓN
# ========================================
NAME="biblioteca_digital"
DJANGODIR=/app
SOCKFILE=/app/run/gunicorn.sock
USER=www-data
GROUP=www-data
NUM_WORKERS=3
DJANGO_SETTINGS_MODULE=biblioteca_project.settings
DJANGO_WSGI_MODULE=biblioteca_project.wsgi
echo "Iniciando $NAME como `whoami`"
# Activar entorno virtual
cd $DJANGODIR
source venv/bin/activate
# Variables de entorno
export DJANGO_SETTINGS_MODULE=$DJANGO_SETTINGS_MODULE
export PYTHONPATH=$DJANGODIR:$PYTHONPATH
# Crear directorio para socket
RUNDIR=$(dirname $SOCKFILE)
test -d $RUNDIR || mkdir -p $RUNDIR
# Ejecutar Gunicorn
exec gunicorn ${DJANGO_WSGI_MODULE}:application \
--name $NAME \
--workers $NUM_WORKERS \
--user=$USER --group=$GROUP \
--bind=unix:$SOCKFILE \
--log-level=info \
--log-file=-
deploy_pythonanywhere.py
PYTHONANYWHERE DEPLOY
📍 deploy_pythonanywhere.py
#!/usr/bin/env python
"""
========================================
SCRIPT DE DEPLOYMENT PARA PYTHONANYWHERE
========================================
Ejecutar este script en PythonAnywhere console
"""
import os
import sys
def deploy():
print("🚀 Iniciando deployment en PythonAnywhere...")
# 1. Actualizar código
print("📥 Actualizando código desde GitHub...")
os.system("cd /home/tu_usuario/biblioteca_digital && git pull")
# 2. Instalar dependencias
print("📦 Instalando dependencias...")
os.system("pip3.10 install --user -r /home/tu_usuario/biblioteca_digital/requirements.txt")
# 3. Migrar base de datos
print("🗄️ Aplicando migraciones...")
os.system("cd /home/tu_usuario/biblioteca_digital && python3.10 manage.py migrate --noinput")
# 4. Colectar archivos estáticos
print("🎨 Colectando archivos estáticos...")
os.system("cd /home/tu_usuario/biblioteca_digital && python3.10 manage.py collectstatic --noinput")
# 5. Recargar web app
print("🔄 Recargando aplicación web...")
os.system("touch /var/www/tu_usuario_pythonanywhere_com_wsgi.py")
print("✅ Deployment completado!")
print("🌐 Visita: https://tu_usuario.pythonanywhere.com")
if __name__ == "__main__":
deploy()
wsgi_pythonanywhere.py
WSGI PYTHONANYWHERE
📍 wsgi_pythonanywhere.py
# ========================================
# WSGI PARA PYTHONANYWHERE
# ========================================
# Copiar este contenido al archivo WSGI de PythonAnywhere
import os
import sys
# Añadir proyecto al path
path = '/home/tu_usuario/biblioteca_digital'
if path not in sys.path:
sys.path.insert(0, path)
# Configurar variables de entorno
os.environ['DJANGO_SETTINGS_MODULE'] = 'biblioteca_project.settings'
# Cargar variables desde .env
from dotenv import load_dotenv
load_dotenv(os.path.join(path, '.env'))
# Inicializar Django
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()
# Para servir archivos estáticos con WhiteNoise (recomendado)
# from whitenoise import WhiteNoise
# application = WhiteNoise(application, root='/home/tu_usuario/biblioteca_digital/staticfiles')
# application.add_files('/home/tu_usuario/biblioteca_digital/media', prefix='media/')
requirements_prod.txt
REQUIREMENTS PRODUCCIÓN
📍 requirements_prod.txt
# ======================================== # DEPENDENCIAS PARA PRODUCCIÓN # ======================================== # Core Django Django==4.2.7 djangorestframework==3.14.0 # Autenticación djangorestframework-simplejwt==5.3.0 django-oauth-toolkit==2.3.0 # WebSockets channels==4.0.0 channels-redis==4.1.0 daphne==4.0.0 # GraphQL graphene-django==3.1.5 # Base de datos mysqlclient==2.2.0 # CORS y Seguridad django-cors-headers==4.3.0 # Redis redis==5.0.1 # Utilidades requests==2.31.0 django-filter==23.5 python-decouple==3.8 # Producción gunicorn==21.2.0 whitenoise==6.6.0 # Monitoreo (opcional) sentry-sdk==1.39.1 # Testing (opcional) pytest==7.4.3 pytest-django==4.7.0
Makefile
COMANDOS ÚTILES
📍 Makefile
# ======================================== # MAKEFILE - COMANDOS ÚTILES # ======================================== .PHONY: help install migrate run test clean docker-build docker-up docker-down help: @echo "🚀 Comandos disponibles:" @echo " make install - Instalar dependencias" @echo " make migrate - Ejecutar migraciones" @echo " make run - Ejecutar servidor de desarrollo" @echo " make test - Ejecutar tests" @echo " make clean - Limpiar archivos temporales" @echo " make docker-build - Construir imagen Docker" @echo " make docker-up - Iniciar contenedores Docker" @echo " make docker-down - Detener contenedores Docker" install: @echo "📦 Instalando dependencias..." pip install -r requirements.txt migrate: @echo "🗄️ Ejecutando migraciones..." python manage.py makemigrations python manage.py migrate run: @echo "🚀 Iniciando servidor de desarrollo..." daphne -b 0.0.0.0 -p 8000 biblioteca_project.asgi:application test: @echo "🧪 Ejecutando tests..." python manage.py test clean: @echo "🧹 Limpiando archivos temporales..." find . -type f -name '*.pyc' -delete find . -type d -name '__pycache__' -delete rm -rf .pytest_cache rm -rf htmlcov rm -rf .coverage docker-build: @echo "🐳 Construyendo imagen Docker..." docker-compose build docker-up: @echo "🐳 Iniciando contenedores Docker..." docker-compose up -d docker-down: @echo "🐳 Deteniendo contenedores Docker..." docker-compose down superuser: @echo "👤 Creando superusuario..." python manage.py createsuperuser collectstatic: @echo "🎨 Colectando archivos estáticos..." python manage.py collectstatic --noinput
README.md
DOCUMENTACIÓN
📍 README.md
# 🚀 Proyecto Django REST + JWT + OAuth + WebSockets
Aplicación completa con Django REST Framework, autenticación JWT, OAuth 2.0 y WebSockets en tiempo real.
## 📋 Características
- ✅ Django REST Framework
- ✅ Autenticación JWT (JSON Web Tokens)
- ✅ OAuth 2.0 (Google y GitHub)
- ✅ WebSockets con Django Channels
- ✅ Base de datos MySQL
- ✅ Redis para WebSockets
- ✅ Docker & Docker Compose
- ✅ API RESTful completa
## 🛠️ Instalación
### Opción 1: Sin Docker
1. **Clonar el repositorio**
```bash
git clone <tu-repo>
cd proyecto_django
```
2. **Crear entorno virtual**
```bash
python -m venv venv
source venv/bin/activate # En Windows: venv\Scripts\activate
```
3. **Instalar dependencias**
```bash
pip install -r requirements.txt
```
4. **Configurar variables de entorno**
```bash
cp .env.example .env
# Editar .env con tus configuraciones
```
5. **Configurar MySQL**
```bash
# Crear base de datos en MySQL
mysql -u root -p
CREATE DATABASE mi_base_datos;
```
6. **Migrar base de datos**
```bash
python manage.py makemigrations
python manage.py migrate
```
7. **Crear superusuario**
```bash
python manage.py createsuperuser
```
8. **Iniciar Redis**
```bash
redis-server
```
9. **Ejecutar servidor**
```bash
python manage.py runserver
# O para WebSockets:
daphne biblioteca_digital.asgi:application
```
### Opción 2: Con Docker
1. **Construir y ejecutar**
```bash
docker-compose up --build
```
2. **Acceder a la aplicación**
```
http://localhost:8000
```
## 🔑 Configuración OAuth
### Google OAuth
1. Ir a [Google Cloud Console](https://console.cloud.google.com/)
2. Crear un proyecto
3. Habilitar Google+ API
4. Crear credenciales OAuth 2.0
5. Agregar `http://localhost:8000/api/auth/google/callback/` como URI de redirección
6. Copiar Client ID y Client Secret al `.env`
### GitHub OAuth
1. Ir a Settings > Developer settings > OAuth Apps
2. Crear nueva OAuth App
3. Agregar `http://localhost:8000/api/auth/github/callback/` como callback URL
4. Copiar Client ID y Client Secret al `.env`
## 📡 Endpoints API
### Autenticación
- `POST /api/register/` - Registro de usuario
- `POST /api/login/` - Login con JWT
- `POST /api/logout/` - Logout
- `GET /api/profile/` - Perfil del usuario
- `POST /api/token/refresh/` - Refrescar token
### OAuth
- `GET /api/auth/google/` - Login con Google
- `GET /api/auth/github/` - Login con GitHub
### Productos
- `GET /api/products/` - Listar productos
- `POST /api/products/` - Crear producto
- `GET /api/products/{id}/` - Detalle de producto
- `PUT /api/products/{id}/` - Actualizar producto
- `DELETE /api/products/{id}/` - Eliminar producto
### WebSocket
- `ws://localhost:8000/ws/chat/{room_name}/` - Chat en tiempo real
## 🧪 Testing
```bash
python manage.py test
```
## 📦 Producción
1. Configurar variables de entorno para producción
2. Configurar HTTPS
3. Usar Nginx como proxy inverso
4. Configurar certificado SSL
## 📄 Licencia
MIT