Desarrollo móvil Intermedio–avanzado

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 …

Publicado: 12/08/2026 Por: Juan Felipe Orozco Cortés Duración: 300 minutos Nivel: Intermedio–avanzado
Contenido del tutorial

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.

Frontend
Ionic y Angular administrarán la interfaz, los formularios, los filtros y las llamadas HTTP.
Backend
Django REST Framework expondrá la API y concentrará las reglas del dominio.
Persistencia
PostgreSQL conservará las tareas y sus datos de auditoría.
Android
Capacitor permitirá reutilizar el frontend dentro de una aplicación Android.
Idea que debes conservar desde el principio
La aplicación tendrá varias tecnologías, pero seguirá existiendo una sola fuente de verdad para los datos: el backend y su 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.

Resultado final
Cliente + API + base de datos + Android
Una operación iniciada desde Ionic atravesará Django REST Framework, llegará a PostgreSQL y regresará al cliente como JSON para actualizar la interfaz.

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.

Python 3.12
Ejecutará Django, Django REST Framework y las pruebas del backend.
PostgreSQL 16
Será la base de datos principal. Durante el tutorial podremos ejecutarla mediante Docker.
Node.js y npm
Se utilizarán para instalar y construir Ionic y Angular.
Android Studio
Proporcionará el SDK de Android, Java y las herramientas necesarias para generar el APK.

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.

No necesitas empezar por Android. Primero construiremos y comprobaremos el backend y la aplicación Ionic desde el navegador. El SDK de Android será realmente necesario cuando lleguemos a la etapa de Capacitor.
Antes de continuar
Comprueba que puedes ejecutar Python, Git, Node.js y npm desde una terminal. También debes tener disponible Docker o una instalación compatible de PostgreSQL.

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.

Estructura de proyecto
Ionic / Angular
      │
      │ HTTP + JSON
      ▼
Django REST Framework
      │
      │ Django ORM
      ▼
PostgreSQL 16

Podemos pensar en estas capas como una cadena de responsabilidades.

1. Presentación Ionic muestra los datos y recoge las acciones del usuario.
2. Comunicación Angular utiliza HTTP para enviar y recibir representaciones JSON.
3. Aplicación Django REST Framework procesa la operación y aplica las reglas del sistema.
4. Persistencia El ORM traduce las operaciones del backend a consultas sobre PostgreSQL.
Regla arquitectónica
El cliente solicita operaciones; el backend decide cómo deben ejecutarse y PostgreSQL conserva el estado persistente.

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.

1. El usuario completa el formulario Angular conserva temporalmente los valores introducidos en la interfaz.
2. El cliente construye una petición HTTP El servicio de Angular transforma los datos necesarios en una solicitud para la API.
3. Django recibe el JSON Django REST Framework valida que los datos cumplan el contrato esperado por el servidor.
4. Se aplica la lógica del backend El servidor completa los valores que pertenecen al dominio y prepara la nueva entidad.
5. El ORM escribe en PostgreSQL La tarea queda persistida y obtiene su identidad definitiva.
6. La API devuelve una respuesta Django convierte la entidad almacenada en una representación JSON.
7. Angular actualiza la interfaz El cliente recibe la respuesta y vuelve a mostrar el estado actual del tablero.

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.

Una simplificación deliberada: esta versión utiliza un usuario fijo configurado en el backend y todavía no implementa autenticación. Más adelante, una aplicación de producción podría sustituir este mecanismo por JWT, OAuth u otro sistema de identidad.
Por qué definir esto ahora
Cuando construyamos el modelo, el serializer y los endpoints, no estaremos inventando reglas sobre la marcha. Cada componente implementará decisiones que ya conocemos.

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:

Estructura de proyecto
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
Backend
backend/
Contendrá Django, la API REST, el modelo de tareas, las migraciones, las pruebas y la configuración de PostgreSQL.
Frontend
mobile/
Contendrá Ionic y Angular. Más adelante, Capacitor generará dentro de esta misma zona el proyecto nativo de Android.
Infraestructura local
docker-compose.yml
Nos permitirá ejecutar PostgreSQL de manera reproducible durante el desarrollo.
Etapa I completada
Antes de comenzar con Django ya sabemos qué construiremos, qué tecnologías participan, cómo viajan los datos, cuáles son las reglas fundamentales del dominio y cómo estará organizado el repositorio.

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.

Archivo
docker-compose.yml
Crea este archivo en la raíz del proyecto, al mismo nivel en el que más adelante tendremos las carpetas backend y mobile.
YAML
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.

Credenciales de desarrollo: el usuario y la contraseña mostrados aquí pertenecen únicamente al entorno local del tutorial. Una aplicación desplegada debe utilizar secretos independientes y protegidos.
Bash
docker compose up -d db
docker compose ps
Resultado esperado
El servicio 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.

Nuevo archivo
backend/requirements.txt
Conservaremos las dependencias del backend en un archivo reproducible.
Texto plano
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
PowerShell
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.

config/
Configuración general, URLs, base de datos y ajustes de Django.
tasks/
Modelo, serializer, endpoints y reglas relacionadas con las tareas.
Comprobación rápida
La carpeta 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.

Nuevo archivo
backend/.env.example
Este archivo puede versionarse porque documenta la estructura de configuración sin contener secretos reales de producción.
Texto plano
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
PowerShell
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.
El archivo .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.

Python
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"
Python
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",
]
Python
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.

Primera validación del backend
Con PostgreSQL ejecutándose y el archivo .env creado, Django ya debe ser capaz de validar su configuración.
Bash
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.

Archivo
backend/tasks/models.py
Aquí vivirá el modelo que Django traducirá posteriormente a una tabla de PostgreSQL.
Python
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.

Python
class Status(models.TextChoices):
    PENDING = "pendiente", "Pendiente"
    COMPLETED = "completada", "Completada"
    POSTPONED = "pospuesta", "Pospuesta"
Python
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.

Python
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.

Auditoría no es autenticación
Todavía no estamos identificando usuarios mediante una sesión o un token. Por ahora almacenaremos el identificador configurado por el backend y más adelante veremos dónde se asigna.

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.

Python
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)
        )
Python
deleted_at = models.DateTimeField(
    null=True,
    blank=True,
    db_index=True,
)

objects = ActiveTaskManager()
all_objects = models.Manager()
Python
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.

Task.objects
Devuelve únicamente las tareas cuya fecha de eliminación sigue vacía.
Task.all_objects
Permite acceder internamente también a las tareas eliminadas.

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ó.

Python
from datetime import timedelta
Python
@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()
    )
Python
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.

Bash
python manage.py makemigrations
python manage.py migrate
python manage.py check
Qué debe ocurrir
Django debe crear la migración de 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.

Nuevo archivo
backend/tasks/serializers.py
Python
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.

Python
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.

Principio importante
La validación del frontend mejora la experiencia de usuario. La validación del backend protege el contrato de la API. Necesitamos ambas.

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.

Nuevo archivo
backend/tasks/views.py
Python
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.

Texto plano
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.

Python
FIXED_USER = os.getenv(
    "FIXED_USER",
    "candidate@eficacia.local",
)
Python
from django.conf import settings
Python
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.

Esta no es una estrategia de autenticación. Utilizamos un usuario fijo para mantener el tutorial concentrado en la arquitectura fullstack. En un sistema multiusuario estos valores provendrían del usuario autenticado.

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.

Python
from rest_framework import status, viewsets
from rest_framework.response import Response
Python
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.

Python
from django_filters.rest_framework import (
    DjangoFilterBackend,
)
Python
filter_backends = [
    DjangoFilterBackend
]

filterset_fields = [
    "status"
]
Texto plano
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.

Python
from rest_framework.decorators import action
Python
@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
    )
JSON
{
  "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.

Python
from datetime import timedelta

from django.utils import timezone
Python
@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)
Texto plano
GET /api/tasks/upcoming/
Por qué hacerlo en el servidor
Si mañana añadimos otro cliente, no tendrá que volver a implementar qué significa “próxima a vencer”. Todos recibirán exactamente la misma interpretación desde la API.

Consultar las tareas asignadas

También crearemos una colección especializada que devuelva únicamente las tareas asociadas al usuario configurado.

Python
@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)
Texto plano
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.

Nuevo archivo
backend/tasks/urls.py
Python
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.

Archivo
backend/config/urls.py
Python
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.

Texto plano
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.

Bash
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

PowerShell
$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.

PowerShell
$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

PowerShell
Invoke-RestMethod "$Api/tasks/"

Cambiar el estado

PowerShell
Invoke-RestMethod `
    -Method Patch `
    -Uri "$Api/tasks/$($Created.id)/status/" `
    -ContentType "application/json" `
    -Body '{"status":"pospuesta"}'

Comprobar el filtro

PowerShell
Invoke-RestMethod `
    "$Api/tasks/?status=pospuesta"

Consultar próximas a vencer y asignadas

PowerShell
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.

PowerShell
Invoke-WebRequest `
    -Method Delete `
    -Uri "$Api/tasks/$($Created.id)/"

Invoke-RestMethod "$Api/tasks/"
Etapa II completada
Nuestro backend ya puede conectarse con PostgreSQL, persistir tareas, serializarlas a JSON, validar entradas, filtrarlas, cambiar su estado, detectar próximas a vencer, consultar asignaciones y aplicar eliminación lógica.

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.

Frontend
mobile/
Aquí construiremos los modelos TypeScript, el servicio HTTP, el dashboard y los formularios que posteriormente empaquetaremos para Android.
Bash
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.

Punto de partida
El cliente debe compilar antes de conectarlo con Django. Así podremos distinguir posteriormente entre un error propio del frontend y un problema de comunicación con la API.

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.

Nuevo archivo
mobile/src/app/core/models/task.model.ts
TypeScript
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',
    },
  ];
Task
Representa una tarea completa recibida desde Django, incluidos su ID, auditoría y campos calculados.
TaskPayload
Representa únicamente los datos que el cliente puede enviar al crear o modificar una tarea.
TaskStatus
Impide utilizar desde TypeScript un estado que no pertenezca al contrato de la API.

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.

Python
# 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",
]
Python
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",
]
Python
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
TypeScript
// mobile/src/environments/environment.ts

export const environment = {
  production: false,
  apiUrl: 'http://127.0.0.1:8000/api',
};
Una URL, un único lugar
Los servicios Angular no escribirán repetidamente la dirección de Django. Todos partirán de 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.

Nuevo archivo
mobile/src/app/core/services/task.service.ts
TypeScript
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.

TypeScript
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.

Separación de responsabilidades
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.

TypeScript
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();
TypeScript
// 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.

TypeScript
import {
  HttpClientModule
} from '@angular/common/http';
TypeScript
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.

TypeScript
import {
  finalize,
  forkJoin,
} from 'rxjs';
TypeScript
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.';
      },
    });
}
GET /tasks/ Obtiene las tareas correspondientes al filtro actual.
GET /tasks/upcoming/ Obtiene las tareas que vencen durante las próximas 48 horas.
forkJoin Espera ambas respuestas antes de actualizar el dashboard.

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=....

TypeScript
applyFilter(
  status: TaskStatus | 'todas'
): void {

  this.selectedStatus = status;
  this.loadDashboard();
}
El backend sigue mandando
Angular indica qué estado desea consultar, pero Django continúa siendo quien aplica el filtro sobre los datos persistidos.

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.

TypeScript
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();
}
TypeScript
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.

TypeScript
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);
}
TypeScript
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.

TypeScript
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.'
        ),
    });
}

Estás viendo solo el 60% del contenido. Hazte Premium para acceder al tutorial completo.

Comunidad

Comentarios y valoraciones

No hay comentarios aún. ¡Sé el primero en opinar!