Construye una app Fullstack con Django REST, PostgreSQL, Angular, Ionic y Android
Aprende a construir una aplicación fullstack de gestión de tareas conectando Ionic y Angular con una API creada en Django REST Framework y PostgreSQL. Implementaremos CRUD, filtros, soft delete, reglas de negocio …
Contenido del tutorial ⌄
- De una interfaz móvil hasta PostgreSQL: el recorrido completo de una aplicación fullstack
- Qué vamos a construir
- Preparar el entorno de desarrollo
- La arquitectura que utilizaremos
- Qué ocurre cuando el usuario crea una tarea
- Reglas del dominio antes de escribir el modelo
- Estados permitidos
- Fecha límite
- Eliminación lógica
- Tareas próximas a vencer
- Auditoría
- Cómo organizaremos el proyecto
- Levantar PostgreSQL con Docker
- Crear el backend con Django REST Framework
- Separar la configuración con variables de entorno
- Conectar Django con PostgreSQL
- Crear nuestra primera entidad: Task
- Limitar los estados permitidos
- Conservar quién hizo qué y cuándo
- Eliminar sin destruir el registro: soft delete
- Saber cuándo una tarea requiere atención
- Convertir el modelo Python en una tabla PostgreSQL
- Convertir nuestras tareas a JSON
- No confiar únicamente en el formulario
- Exponer Task mediante una API REST
- Un ViewSet, varias operaciones REST
- Asignar la identidad desde el servidor
- Cambiar la semántica interna de DELETE
- Filtrar desde el backend
- Crear un endpoint específico para cambiar el estado
- Centralizar la regla de próximas a vencer
- Consultar las tareas asignadas
- Publicar finalmente nuestros endpoints
- Probar la API antes de construir Angular
- Comprobar el estado del servicio
- Crear una tarea real
- Consultar las tareas
- Cambiar el estado
- Comprobar el filtro
- Consultar próximas a vencer y asignadas
- Comprobar el soft delete
- Construir el cliente con Ionic y Angular
- Representar una tarea en TypeScript
- Permitir que Angular se comunique con Django
- Encapsular HTTP dentro de TaskService
- Las peticiones HTTP son asíncronas
- Crear el estado del dashboard
- Cargar dos recursos con una sola operación del dashboard
- Filtrar sin duplicar la regla en Angular
- Reutilizar un formulario para crear y editar
- Traducir fechas entre el formulario y la API
- Conectar el selector con nuestro endpoint de estado
De una interfaz móvil hasta PostgreSQL: el recorrido completo de una aplicación fullstack
Construir una pantalla que permita crear tareas es relativamente sencillo. Lo interesante comienza cuando esa tarea debe viajar por una API, validarse en el servidor, almacenarse en una base de datos y volver al cliente convertida en una respuesta que la interfaz pueda mostrar.
En este tutorial construiremos ese recorrido completo. Partiremos de una aplicación cliente creada con Ionic y Angular, utilizaremos Django REST Framework como backend y PostgreSQL como sistema de persistencia. Finalmente empaquetaremos el mismo frontend como una aplicación Android mediante Capacitor.
El objetivo no será únicamente conseguir que la aplicación funcione. Seguiremos cada operación para comprender qué responsabilidad pertenece al cliente, cuál pertenece al servidor y qué información termina almacenándose en la base de datos.
Qué vamos a construir
El proyecto será un gestor de tareas fullstack. No utilizaremos datos simulados en la interfaz: las operaciones del usuario viajarán hasta una API REST real y las tareas quedarán almacenadas en PostgreSQL.
Al terminar tendremos una aplicación capaz de cubrir el ciclo de vida principal de una tarea:
- Crear una tarea con título, descripción, estado y fecha límite.
- Consultar las tareas existentes.
- Editar una tarea previamente almacenada.
- Filtrar las tareas según su estado.
- Cambiar rápidamente entre pendiente, pospuesta y completada.
- Eliminar una tarea mediante eliminación lógica.
- Identificar tareas próximas a vencer.
- Conservar información de creación, modificación y asignación.
La misma interfaz podrá ejecutarse inicialmente en el navegador y posteriormente dentro de Android. Esto nos permitirá concentrarnos primero en el comportamiento del sistema y dejar el empaquetado móvil para una etapa posterior.
Preparar el entorno de desarrollo
Antes de crear el backend conviene comprobar que el equipo dispone de las herramientas que utilizaremos durante todo el proyecto.
La implementación de referencia utiliza Django 5.2.17, Django REST Framework 3.17.2, Angular 20.3.25, Ionic 8, Capacitor 8.5.0 y Java 21.
También necesitaremos PowerShell para ejecutar los comandos mostrados en el tutorial y Git para mantener el proyecto bajo control de versiones.
La arquitectura que utilizaremos
El sistema seguirá una arquitectura cliente-servidor. Ionic y Angular no accederán directamente a PostgreSQL. En su lugar enviarán solicitudes HTTP a Django REST Framework.
Django será responsable de interpretar esas solicitudes, validar los datos, ejecutar las reglas del dominio y utilizar su ORM para consultar o modificar PostgreSQL.
La separación es importante porque permite reutilizar la misma API desde distintos clientes. En nuestro caso, el frontend podrá ejecutarse como sitio web durante el desarrollo o dentro de Android mediante Capacitor sin cambiar la responsabilidad del backend.
Ionic / Angular
│
│ HTTP + JSON
▼
Django REST Framework
│
│ Django ORM
▼
PostgreSQL 16
Podemos pensar en estas capas como una cadena de responsabilidades.
Qué ocurre cuando el usuario crea una tarea
Antes de escribir código vamos a seguir mentalmente una operación completa. Imagina que el usuario abre el formulario y crea una tarea llamada Preparar informe.
Las operaciones de consulta, edición y cambio de estado recorrerán una ruta muy parecida. Comprender este recorrido hará que posteriormente cada archivo del proyecto tenga una función fácil de identificar.
Reglas del dominio antes de escribir el modelo
Una API no debería limitarse a guardar cualquier dato que reciba. Antes de crear nuestro modelo necesitamos establecer qué significa una tarea dentro de este sistema.
Estados permitidos
Cada tarea tendrá uno de tres estados:
- Pendiente: todavía debe realizarse.
- Pospuesta: sigue activa, pero fue aplazada.
- Completada: el trabajo ya terminó.
Fecha límite
Una tarea podrá tener una fecha y hora límite. Utilizaremos este dato para identificar aquellas que requieren atención próximamente.
Eliminación lógica
Al eliminar una tarea no borraremos inmediatamente su fila de PostgreSQL. Guardaremos el momento de eliminación y dejaremos de mostrarla en las consultas normales.
Esta estrategia se conoce como soft delete y nos permite conservar información histórica y de auditoría.
Tareas próximas a vencer
El servidor considerará próxima una tarea activa cuya fecha límite se encuentre entre el momento actual y las siguientes 48 horas. Las tareas completadas o eliminadas no formarán parte de esta consulta.
Auditoría
Además de los datos visibles para el usuario, conservaremos información como la fecha de creación, la última modificación, el usuario creador, el usuario asignado y la fecha de eliminación lógica.
Cómo organizaremos el proyecto
Mantendremos backend y frontend dentro del mismo repositorio, pero como aplicaciones independientes. Esta separación permite ejecutar, probar y evolucionar cada parte sin mezclar responsabilidades.
Al terminar, la estructura principal tendrá esta forma:
proyecto-fullstack/
├── backend/
│ ├── config/
│ ├── tasks/
│ ├── .env.example
│ ├── manage.py
│ └── requirements.txt
│
├── mobile/
│ ├── src/
│ │ ├── app/
│ │ └── environments/
│ ├── android/
│ ├── capacitor.config.ts
│ └── package.json
│
├── docs/
│
├── docker-compose.yml
└── README.md
Con esta base podemos comenzar la implementación sin saltar directamente al código. La siguiente etapa será levantar PostgreSQL y construir progresivamente el backend con Django REST Framework.
Levantar PostgreSQL con Docker
Nuestra primera pieza ejecutable será la base de datos. En lugar de instalar PostgreSQL directamente en el sistema operativo, lo ejecutaremos dentro de un contenedor Docker.
Esto nos proporciona un entorno reproducible: el proyecto especifica qué versión de PostgreSQL utiliza, qué base debe crearse, qué puerto expondrá y dónde permanecerán almacenados sus datos.
services:
db:
image: postgres:16-alpine
container_name: eficacia_tasks_db
restart: unless-stopped
environment:
POSTGRES_DB: tasks_db
POSTGRES_USER: tasks_user
POSTGRES_PASSWORD: tasks_password
ports:
- "5434:5432"
volumes:
- eficacia_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tasks_user -d tasks_db"]
interval: 5s
timeout: 5s
retries: 10
volumes:
eficacia_postgres_data:
La notación 5434:5432 conecta el puerto 5434 del computador con el puerto 5432 utilizado por PostgreSQL dentro del contenedor.
El volumen eficacia_postgres_data evita que las tareas desaparezcan al detener o recrear el contenedor.
docker compose up -d db
docker compose ps
db debe aparecer en ejecución y, después de unos segundos, su estado de salud debe indicar que PostgreSQL está preparado para aceptar conexiones.
Desde el computador podremos alcanzar la base mediante 127.0.0.1:5434. Django utilizará esa dirección en la siguiente parte del proyecto.
Crear el backend con Django REST Framework
Con PostgreSQL funcionando podemos crear la segunda capa del sistema: el backend.
Dentro de backend/ tendremos el proyecto Django llamado config y una aplicación llamada tasks. La primera concentrará la configuración global y la segunda contendrá el dominio de las tareas.
asgiref==3.12.1
dj-database-url==3.1.2
Django==5.2.17
django-cors-headers==4.9.0
django-filter==26.1
djangorestframework==3.17.2
psycopg==3.3.4
psycopg-binary==3.3.4
python-dotenv==1.2.2
sqlparse==0.5.5
typing_extensions==4.16.0
tzdata==2026.3
mkdir backend
cd backend
py -3.12 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
pip install -r requirements.txt
django-admin startproject config .
python manage.py startapp tasks
Después de ejecutar estos comandos ya existen dos niveles diferentes.
backend/ debe contener ahora manage.py, config/, tasks/ y requirements.txt.
Separar la configuración con variables de entorno
La dirección de PostgreSQL, la clave de Django y otras decisiones del entorno no deberían quedar dispersas entre los archivos Python.
Crearemos un archivo de referencia que documente las variables necesarias y después una copia local llamada .env.
DEBUG=True
SECRET_KEY=replace-this-development-key
DATABASE_URL=postgresql://tasks_user:tasks_password@127.0.0.1:5434/tasks_db
FIXED_USER=candidate@eficacia.local
Copy-Item .env.example .env
La URL de conexión contiene las piezas que configuramos previamente en Docker:
- Usuario: tasks_user.
- Contraseña: tasks_password.
- Host: 127.0.0.1.
- Puerto: 5434.
- Base de datos: tasks_db.
.env es local y no debería incluirse en Git cuando contiene secretos reales.
Conectar Django con PostgreSQL
Ahora modificaremos la configuración para que Django deje de depender de la base creada por defecto y utilice PostgreSQL.
También registraremos Django REST Framework, django-filter y nuestra aplicación tasks, porque serán necesarias durante la construcción de la API.
from pathlib import Path
import os
import dj_database_url
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
SECRET_KEY = os.getenv(
"SECRET_KEY",
"django-insecure-development-only",
)
DEBUG = os.getenv("DEBUG", "False").lower() == "true"
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_filters",
"rest_framework",
"tasks",
]
database_url = os.getenv("DATABASE_URL")
if not database_url:
raise RuntimeError(
"DATABASE_URL no está configurada."
)
DATABASES = {
"default": dj_database_url.parse(
database_url,
conn_max_age=60,
)
}
Al iniciar Django, load_dotenv() carga las variables del archivo local. Después dj_database_url transforma la cadena de conexión en la estructura que espera Django.
.env creado, Django ya debe ser capaz de validar su configuración.
python manage.py check
Crear nuestra primera entidad: Task
Una tarea necesita una representación persistente antes de poder exponerla mediante una API.
Comenzaremos con los datos fundamentales: un título, una descripción opcional y una fecha límite. En las siguientes secciones añadiremos los estados, auditoría y eliminación lógica.
from django.db import models
class Task(models.Model):
title = models.CharField(
max_length=200
)
description = models.TextField(
blank=True
)
due_date = models.DateTimeField(
db_index=True
)
def __str__(self):
return self.title
CharField limita el tamaño del título, mientras que TextField permite una descripción más extensa. La fecha límite utiliza DateTimeField porque necesitamos conservar tanto el día como la hora.
Hemos añadido un índice a due_date porque posteriormente consultaremos tareas utilizando precisamente este campo.
Limitar los estados permitidos
Guardar cualquier string dentro del campo de estado permitiría valores inconsistentes como finalizada, hecha o incluso errores ortográficos.
Django ofrece TextChoices para definir un conjunto cerrado de valores válidos.
class Status(models.TextChoices):
PENDING = "pendiente", "Pendiente"
COMPLETED = "completada", "Completada"
POSTPONED = "pospuesta", "Pospuesta"
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PENDING,
db_index=True,
)
PostgreSQL almacenará valores como pendiente, mientras que Django dispone además de etiquetas legibles como Pendiente.
La aplicación comenzará cada nueva tarea como pendiente salvo que el cliente indique explícitamente otro estado válido.
Conservar quién hizo qué y cuándo
Una aplicación real suele necesitar más información que aquella que aparece directamente en el formulario.
Añadiremos campos de auditoría para registrar quién creó la tarea, a quién pertenece y cuándo fue creada o modificada.
created_by = models.CharField(
max_length=255
)
assigned_to = models.CharField(
max_length=255,
db_index=True,
)
created_at = models.DateTimeField(
auto_now_add=True
)
updated_at = models.DateTimeField(
auto_now=True
)
auto_now_add registra automáticamente el instante de creación. auto_now, en cambio, actualiza el campo cada vez que guardamos cambios.
Eliminar sin destruir el registro: soft delete
Cuando el usuario elimine una tarea queremos que desaparezca de la aplicación, pero no necesitamos borrar inmediatamente su registro de PostgreSQL.
Guardaremos el momento de eliminación en deleted_at y crearemos un manager que devuelva únicamente tareas activas.
from django.utils import timezone
class ActiveTaskManager(models.Manager):
"""Devuelve únicamente tareas no eliminadas."""
def get_queryset(self):
return (
super()
.get_queryset()
.filter(deleted_at__isnull=True)
)
deleted_at = models.DateTimeField(
null=True,
blank=True,
db_index=True,
)
objects = ActiveTaskManager()
all_objects = models.Manager()
def soft_delete(self):
self.deleted_at = timezone.now()
self.save(
update_fields=[
"deleted_at",
"updated_at",
]
)
A partir de ahora existirán dos formas intencionalmente distintas de consultar el modelo.
Esta decisión hará que los endpoints normales excluyan automáticamente registros eliminados sin repetir el filtro en cada consulta.
Saber cuándo una tarea requiere atención
El modelo también puede responder preguntas sobre su propio estado. Implementaremos dos propiedades: una para determinar si la tarea vence durante las próximas 48 horas y otra para saber si ya venció.
from datetime import timedelta
@property
def is_due_soon(self):
now = timezone.now()
limit = now + timedelta(hours=48)
return (
self.status != self.Status.COMPLETED
and now <= self.due_date <= limit
)
@property
def is_overdue(self):
return (
self.status != self.Status.COMPLETED
and self.due_date < timezone.now()
)
class Meta:
ordering = [
"due_date",
"-created_at",
]
indexes = [
models.Index(
fields=["status", "due_date"]
),
models.Index(
fields=[
"assigned_to",
"deleted_at",
]
),
]
Las propiedades calculan información a partir de los campos existentes; no necesitamos almacenar columnas adicionales para indicar si una tarea está vencida.
Los índices compuestos acompañan consultas que utilizaremos posteriormente para estado, fechas, asignación y eliminación lógica.
Convertir el modelo Python en una tabla PostgreSQL
Definir una clase en Python todavía no modifica la base de datos. Django utiliza migraciones para describir y aplicar los cambios de esquema.
python manage.py makemigrations
python manage.py migrate
python manage.py check
Task, aplicar las tablas necesarias y finalizar check sin errores.
A partir de este momento PostgreSQL ya posee una representación persistente de nuestro dominio.
Convertir nuestras tareas a JSON
El modelo representa la información dentro de Django, pero Angular no recibirá objetos Python. La comunicación HTTP utilizará JSON.
Un serializer de Django REST Framework define qué información puede salir de la API, qué valores puede enviar el cliente y cómo se validan.
from rest_framework import serializers
from .models import Task
class TaskSerializer(
serializers.ModelSerializer
):
is_due_soon = serializers.BooleanField(
read_only=True
)
is_overdue = serializers.BooleanField(
read_only=True
)
class Meta:
model = Task
fields = [
"id",
"title",
"description",
"status",
"due_date",
"created_by",
"assigned_to",
"created_at",
"updated_at",
"is_due_soon",
"is_overdue",
]
read_only_fields = [
"id",
"created_by",
"assigned_to",
"created_at",
"updated_at",
"is_due_soon",
"is_overdue",
]
Observa una decisión importante: el cliente puede proporcionar datos como título, descripción, estado y fecha límite, pero no puede decidir quién creó la tarea ni alterar sus marcas de auditoría.
Los campos is_due_soon e is_overdue se calculan desde el modelo y únicamente aparecen en la respuesta.
No confiar únicamente en el formulario
Incluso cuando Angular valide el formulario, cualquier cliente HTTP podría llamar directamente a la API.
Por eso las reglas importantes deben existir también en el backend.
def validate_title(self, value):
value = value.strip()
if not value:
raise serializers.ValidationError(
"El título no puede estar vacío."
)
return value
El método elimina espacios al principio y al final. Una entrada compuesta únicamente por espacios termina convertida en una cadena vacía y genera una respuesta de validación.
Exponer Task mediante una API REST
Ya podemos almacenar tareas y convertirlas a JSON. Falta conectar esas piezas con solicitudes HTTP.
Utilizaremos un ModelViewSet, una abstracción de Django REST Framework que reúne las operaciones habituales de listar, crear, consultar, modificar y eliminar recursos.
from rest_framework import viewsets
from .models import Task
from .serializers import TaskSerializer
class TaskViewSet(
viewsets.ModelViewSet
):
serializer_class = TaskSerializer
def get_queryset(self):
return Task.objects.all()
La consulta utiliza Task.objects, nuestro manager de tareas activas. Por eso los registros con deleted_at dejarán de aparecer automáticamente.
Un ViewSet, varias operaciones REST
ModelViewSet ya sabe cómo asociar las operaciones HTTP principales con el modelo y su serializer.
GET /api/tasks/
POST /api/tasks/
GET /api/tasks/{id}/
PUT /api/tasks/{id}/
PATCH /api/tasks/{id}/
DELETE /api/tasks/{id}/
Todavía necesitamos registrar estas rutas, pero la lógica base del CRUD ya está representada por el ViewSet.
A partir de aquí extenderemos esa abstracción únicamente en los puntos donde nuestro dominio necesita un comportamiento diferente.
Asignar la identidad desde el servidor
El cliente no debería enviar libremente los campos de auditoría. Para esta versión educativa utilizaremos la variable FIXED_USER que ya definimos en .env.
FIXED_USER = os.getenv(
"FIXED_USER",
"candidate@eficacia.local",
)
from django.conf import settings
def perform_create(self, serializer):
serializer.save(
created_by=settings.FIXED_USER,
assigned_to=settings.FIXED_USER,
)
Cuando llegue un POST, el serializer validará los datos del cliente y perform_create() completará los campos controlados por el servidor.
Cambiar la semántica interna de DELETE
REST puede seguir presentando una operación DELETE al cliente aunque internamente decidamos conservar el registro.
Sobrescribiremos únicamente el comportamiento de destrucción del ViewSet.
from rest_framework import status, viewsets
from rest_framework.response import Response
def destroy(
self,
request,
*args,
**kwargs,
):
task = self.get_object()
task.soft_delete()
return Response(
status=status.HTTP_204_NO_CONTENT
)
Para el cliente la respuesta sigue siendo 204 No Content. En PostgreSQL, sin embargo, el registro permanece y recibe una fecha en deleted_at.
Filtrar desde el backend
Cuando el usuario seleccione “Pendientes”, no necesitamos descargar todas las tareas y descartarlas en Angular.
Podemos expresar el filtro directamente en la consulta HTTP y dejar que Django construya la consulta adecuada.
from django_filters.rest_framework import (
DjangoFilterBackend,
)
filter_backends = [
DjangoFilterBackend
]
filterset_fields = [
"status"
]
GET /api/tasks/?status=pendiente
GET /api/tasks/?status=pospuesta
GET /api/tasks/?status=completada
Crear un endpoint específico para cambiar el estado
Aunque PATCH /tasks/{id}/ podría modificar el campo, una acción explícita facilita esta operación frecuente desde una interfaz móvil.
from rest_framework.decorators import action
@action(
detail=True,
methods=["patch"],
url_path="status",
)
def change_status(self, request, pk=None):
task = self.get_object()
new_status = request.data.get("status")
valid_statuses = {
choice
for choice, _label
in Task.Status.choices
}
if new_status not in valid_statuses:
return Response(
{
"status": (
"Estado inválido. Use "
"pendiente, completada "
"o pospuesta."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
task.status = new_status
task.save(
update_fields=[
"status",
"updated_at",
]
)
return Response(
self.get_serializer(task).data
)
{
"status": "completada"
}
La API no acepta estados arbitrarios. Antes de modificar la tarea compara el valor recibido con los estados definidos por Task.Status.
Centralizar la regla de próximas a vencer
Ahora crearemos uno de los endpoints que realmente añade lógica de negocio a nuestro CRUD.
La API buscará tareas cuya fecha límite esté entre el momento actual y las siguientes 48 horas, excluyendo aquellas que ya fueron completadas.
from datetime import timedelta
from django.utils import timezone
@action(
detail=False,
methods=["get"],
url_path="upcoming",
)
def upcoming(self, request):
now = timezone.now()
limit = now + timedelta(hours=48)
tasks = (
self.get_queryset()
.filter(
due_date__gte=now,
due_date__lte=limit,
)
.exclude(
status=Task.Status.COMPLETED
)
)
serializer = self.get_serializer(
tasks,
many=True,
)
return Response(serializer.data)
GET /api/tasks/upcoming/
Consultar las tareas asignadas
También crearemos una colección especializada que devuelva únicamente las tareas asociadas al usuario configurado.
@action(
detail=False,
methods=["get"],
url_path="assigned",
)
def assigned(self, request):
tasks = self.get_queryset().filter(
assigned_to=settings.FIXED_USER
)
serializer = self.get_serializer(
tasks,
many=True,
)
return Response(serializer.data)
GET /api/tasks/assigned/
Publicar finalmente nuestros endpoints
El ViewSet contiene el comportamiento, pero necesitamos conectarlo al sistema de URLs de Django.
Django REST Framework puede generar automáticamente las rutas estándar y las acciones adicionales mediante un router.
from rest_framework.routers import (
DefaultRouter,
)
from .views import TaskViewSet
router = DefaultRouter()
router.register(
"tasks",
TaskViewSet,
basename="task",
)
urlpatterns = router.urls
Ahora montaremos esas rutas bajo /api/ y añadiremos un endpoint mínimo de salud para comprobar que Django responde incluso antes de consultar tareas.
from django.contrib import admin
from django.http import JsonResponse
from django.urls import include, path
def health_check(request):
return JsonResponse(
{
"status": "ok",
"service": "eficacia-tasks-api",
}
)
urlpatterns = [
path("admin/", admin.site.urls),
path(
"api/health/",
health_check,
),
path(
"api/",
include("tasks.urls"),
),
]
Con el router configurado, nuestra API ya tiene un contrato HTTP completo.
GET /api/health/
GET /api/tasks/
POST /api/tasks/
GET /api/tasks/{id}/
PATCH /api/tasks/{id}/
DELETE /api/tasks/{id}/
PATCH /api/tasks/{id}/status/
GET /api/tasks/?status=pendiente
GET /api/tasks/upcoming/
GET /api/tasks/assigned/
Probar la API antes de construir Angular
Todavía no tenemos interfaz y eso es una ventaja: podemos comprobar el backend de forma aislada.
Si estas operaciones funcionan desde PowerShell, posteriormente cualquier problema de integración con Angular será mucho más fácil de localizar.
python manage.py runserver 0.0.0.0:8000
Mantén esa terminal ejecutándose y abre una segunda PowerShell para realizar las peticiones.
Comprobar el estado del servicio
$Api = "http://127.0.0.1:8000/api"
Invoke-RestMethod "$Api/health/"
Crear una tarea real
Utilizaremos una fecha situada 24 horas en el futuro para que la tarea también pueda aparecer posteriormente en el endpoint de próximas a vencer.
$Payload = @{
title = "Preparar entrega"
description = "Validar el proyecto fullstack"
status = "pendiente"
due_date = (Get-Date).AddHours(24).ToString("o")
} | ConvertTo-Json
$Created = Invoke-RestMethod `
-Method Post `
-Uri "$Api/tasks/" `
-ContentType "application/json" `
-Body $Payload
$Created
La respuesta debe incluir un id generado por la base, los datos enviados, los campos de auditoría asignados por Django y los indicadores calculados por el serializer.
Consultar las tareas
Invoke-RestMethod "$Api/tasks/"
Cambiar el estado
Invoke-RestMethod `
-Method Patch `
-Uri "$Api/tasks/$($Created.id)/status/" `
-ContentType "application/json" `
-Body '{"status":"pospuesta"}'
Comprobar el filtro
Invoke-RestMethod `
"$Api/tasks/?status=pospuesta"
Consultar próximas a vencer y asignadas
Invoke-RestMethod "$Api/tasks/upcoming/"
Invoke-RestMethod "$Api/tasks/assigned/"
Comprobar el soft delete
Terminaremos eliminando la tarea. La API responderá como una eliminación REST normal, pero el modelo conservará internamente el registro con su fecha de eliminación.
Invoke-WebRequest `
-Method Delete `
-Uri "$Api/tasks/$($Created.id)/"
Invoke-RestMethod "$Api/tasks/"
Hasta aquí toda la aplicación vive detrás de solicitudes HTTP. En la siguiente etapa construiremos el cliente con Ionic y Angular y veremos cómo esos endpoints se convierten en una interfaz interactiva.
Construir el cliente con Ionic y Angular
Hasta este punto hemos trabajado exclusivamente detrás de HTTP. PostgreSQL conserva los datos y Django REST Framework expone las operaciones, pero todavía necesitamos una interfaz desde la cual una persona pueda utilizarlas.
La carpeta mobile/ contendrá ese cliente. Angular administrará el estado y la comunicación HTTP, mientras que Ionic aportará componentes preparados para interfaces web y móviles.
cd mobile
npm install
npm run build
Antes de modificar la aplicación conviene comprobar que las dependencias pueden instalarse y que Angular es capaz de construir el proyecto.
Representar una tarea en TypeScript
Django ya tiene un modelo y un serializer, pero Angular también necesita conocer la forma de los objetos que recibirá.
No duplicaremos la lógica del backend. Crearemos tipos que describan su contrato HTTP y permitan que TypeScript detecte errores mientras desarrollamos.
export type TaskStatus =
| 'pendiente'
| 'completada'
| 'pospuesta';
export interface Task {
id: number;
title: string;
description: string;
status: TaskStatus;
due_date: string;
created_by: string;
assigned_to: string;
created_at: string;
updated_at: string;
is_due_soon: boolean;
is_overdue: boolean;
}
export interface TaskPayload {
title: string;
description: string;
status: TaskStatus;
due_date: string;
}
export const TASK_STATUS_OPTIONS:
ReadonlyArray<{
value: TaskStatus;
label: string;
}> = [
{
value: 'pendiente',
label: 'Pendiente',
},
{
value: 'pospuesta',
label: 'Pospuesta',
},
{
value: 'completada',
label: 'Completada',
},
];
Observa que created_by, assigned_to y las fechas de auditoría no aparecen en TaskPayload. Esos valores pertenecen al servidor.
Permitir que Angular se comunique con Django
Durante el desarrollo tendremos dos servidores distintos: Django escuchará en el puerto 8000 e Ionic normalmente servirá la interfaz desde el puerto 8100.
Para el navegador estos son orígenes diferentes. Antes de consumir la API completaremos la configuración CORS del backend.
# backend/config/settings.py
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"corsheaders",
"django_filters",
"rest_framework",
"tasks",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"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",
]
CORS_ALLOWED_ORIGINS = [
"http://localhost:8100",
"http://127.0.0.1:8100",
"http://localhost",
"https://localhost",
"capacitor://localhost",
]
CSRF_TRUSTED_ORIGINS = [
"http://localhost:8100",
"http://127.0.0.1:8100",
"http://localhost",
"https://localhost",
]
CORS_ALLOW_CREDENTIALS = False
// mobile/src/environments/environment.ts
export const environment = {
production: false,
apiUrl: 'http://127.0.0.1:8000/api',
};
environment.apiUrl, lo que facilitará cambiar posteriormente el destino de la API.
Encapsular HTTP dentro de TaskService
La página no debería construir URLs ni decidir cómo se realiza cada petición.
Crearemos un servicio dedicado que represente desde Angular el contrato que acabamos de construir en Django.
import {
HttpClient,
HttpParams,
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import {
environment
} from '../../../environments/environment';
import {
Task,
TaskPayload,
TaskStatus,
} from '../models/task.model';
@Injectable({
providedIn: 'root',
})
export class TaskService {
private readonly baseUrl =
`${environment.apiUrl}/tasks`;
constructor(
private readonly http: HttpClient
) {}
list(
status?: TaskStatus | 'todas'
): Observable<Task[]> {
let params = new HttpParams();
if (status && status !== 'todas') {
params = params.set(
'status',
status
);
}
return this.http.get<Task[]>(
`${this.baseUrl}/`,
{ params }
);
}
upcoming(): Observable<Task[]> {
return this.http.get<Task[]>(
`${this.baseUrl}/upcoming/`
);
}
assigned(): Observable<Task[]> {
return this.http.get<Task[]>(
`${this.baseUrl}/assigned/`
);
}
create(
payload: TaskPayload
): Observable<Task> {
return this.http.post<Task>(
`${this.baseUrl}/`,
payload
);
}
update(
id: number,
payload: Partial<TaskPayload>
): Observable<Task> {
return this.http.patch<Task>(
`${this.baseUrl}/${id}/`,
payload
);
}
changeStatus(
id: number,
status: TaskStatus
): Observable<Task> {
return this.http.patch<Task>(
`${this.baseUrl}/${id}/status/`,
{ status }
);
}
remove(
id: number
): Observable<void> {
return this.http.delete<void>(
`${this.baseUrl}/${id}/`
);
}
}
Las peticiones HTTP son asíncronas
Una llamada a Django no devuelve inmediatamente una tarea. El navegador inicia una operación de red y la respuesta llegará posteriormente.
Por eso los métodos de TaskService devuelven Observable. La página podrá suscribirse para reaccionar cuando lleguen los datos o cuando ocurra un error.
list(): Observable<Task[]>
create(
payload: TaskPayload
): Observable<Task>
remove(
id: number
): Observable<void>
El tipo también describe la respuesta esperada: listar produce varias tareas, crear devuelve una tarea y eliminar no necesita devolver contenido.
TaskService sabe comunicarse con la API. La página decidirá qué hacer visualmente cuando una petición termine.
Crear el estado del dashboard
Una interfaz necesita recordar mucho más que la lista de tareas. También debe saber qué filtro está seleccionado, si existe una petición en curso, si estamos editando y si el formulario está abierto.
Empezaremos declarando ese estado dentro de HomePage.
readonly statusOptions =
TASK_STATUS_OPTIONS;
tasks: Task[] = [];
upcomingTasks: Task[] = [];
selectedStatus:
TaskStatus | 'todas' = 'todas';
loading = false;
saving = false;
errorMessage = '';
formOpen = false;
editingTask: Task | null = null;
form: TaskPayload =
this.emptyForm();
// mobile/src/app/home/home.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { HomePage } from './home.page';
import {
HomePageRoutingModule
} from './home-routing.module';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
HomePageRoutingModule,
],
declarations: [
HomePage,
],
})
export class HomePageModule {}
FormsModule será necesario porque el formulario utilizará ngModel para conectar los componentes Ionic con nuestro objeto form.
import {
HttpClientModule
} from '@angular/common/http';
imports: [
BrowserModule,
HttpClientModule,
IonicModule.forRoot(),
AppRoutingModule,
],
Cargar dos recursos con una sola operación del dashboard
La pantalla necesita dos conjuntos de datos independientes: las tareas correspondientes al filtro seleccionado y las tareas próximas a vencer.
Podemos iniciar ambas peticiones al mismo tiempo mediante forkJoin y actualizar la interfaz cuando las dos hayan terminado.
import {
finalize,
forkJoin,
} from 'rxjs';
ngOnInit(): void {
this.loadDashboard();
}
loadDashboard(): void {
this.loading = true;
this.errorMessage = '';
forkJoin({
tasks: this.taskService.list(
this.selectedStatus
),
upcoming:
this.taskService.upcoming(),
})
.pipe(
finalize(
() => (this.loading = false)
)
)
.subscribe({
next: ({
tasks,
upcoming,
}) => {
this.tasks = tasks;
this.upcomingTasks = upcoming;
},
error: () => {
this.errorMessage =
'No fue posible conectar con la API de tareas.';
},
});
}
Filtrar sin duplicar la regla en Angular
Cuando el usuario selecciona un estado, Angular no filtra localmente la lista completa.
Conservamos el estado seleccionado y solicitamos nuevamente los datos. TaskService se encargará de convertir esa decisión en el parámetro ?status=....
applyFilter(
status: TaskStatus | 'todas'
): void {
this.selectedStatus = status;
this.loadDashboard();
}
Reutilizar un formulario para crear y editar
No necesitamos dos formularios diferentes. La presencia de editingTask será suficiente para saber qué operación debe realizarse.
openCreateForm(): void {
this.editingTask = null;
this.form = this.emptyForm();
this.formOpen = true;
}
openEditForm(task: Task): void {
this.editingTask = task;
this.form = {
title: task.title,
description: task.description,
status: task.status,
due_date:
this.toLocalInput(task.due_date),
};
this.formOpen = true;
}
closeForm(): void {
this.formOpen = false;
this.editingTask = null;
this.form = this.emptyForm();
}
saveTask(): void {
if (
!this.form.title.trim() ||
!this.form.due_date
) {
this.showToast(
'Completa el título y la fecha límite.'
);
return;
}
const payload: TaskPayload = {
...this.form,
title: this.form.title.trim(),
description:
this.form.description.trim(),
due_date:
new Date(
this.form.due_date
).toISOString(),
};
const isEditing =
this.editingTask !== null;
this.saving = true;
const request = this.editingTask
? this.taskService.update(
this.editingTask.id,
payload
)
: this.taskService.create(payload);
request
.pipe(
finalize(
() => (this.saving = false)
)
)
.subscribe({
next: () => {
this.closeForm();
this.showToast(
isEditing
? 'Tarea actualizada.'
: 'Tarea creada.'
);
this.loadDashboard();
},
error: () =>
this.showToast(
'No fue posible guardar la tarea.'
),
});
}
Traducir fechas entre el formulario y la API
Un campo HTML datetime-local trabaja con una fecha local sin incluir explícitamente el huso horario. La API, en cambio, recibe una representación ISO.
Necesitamos convertir en ambas direcciones cuando cargamos una tarea en el formulario y cuando la enviamos a Django.
private toLocalInput(
value: string
): string {
const date = new Date(value);
const offset =
date.getTimezoneOffset() * 60000;
return new Date(
date.getTime() - offset
)
.toISOString()
.slice(0, 16);
}
due_date:
new Date(
this.form.due_date
).toISOString(),
De esta forma el usuario edita una fecha comprensible en su hora local y el cliente envía al backend una representación ISO inequívoca.
Conectar el selector con nuestro endpoint de estado
En el backend creamos deliberadamente PATCH /tasks/{id}/status/. Ahora aprovecharemos ese endpoint desde la interfaz.
changeStatus(
task: Task,
event: CustomEvent
): void {
const status =
event.detail.value as TaskStatus;
if (
!status ||
status === task.status
) {
return;
}
this.taskService
.changeStatus(
task.id,
status
)
.subscribe({
next: () => {
this.showToast(
'Estado actualizado.'
);
this.loadDashboard();
},
error: () =>
this.showToast(
'No fue posible cambiar el estado.'
),
});
}
Comentarios y valoraciones
No hay comentarios aún. ¡Sé el primero en opinar!