# Django

> Sigue el tutorial de encuestas de Django y usa el proyecto desde una ventana de escritorio de Pyzed, con el ORM en el mismo proceso o como cliente HTTP de runserver.

Source: https://albakore.github.io/pyzed/es/integrations/django/

La demo es el proyecto del [tutorial de Django](https://docs.djangoproject.com/en/stable/intro/tutorial01/),
`mysite` con su app `polls`, construido paso a paso como lo hace el tutorial: modelos,
migraciones, el admin, vistas genéricas y plantillas. Después, una ventana de Pyzed vota en las
encuestas de dos formas, que corresponden a dos [arquitecturas](https://albakore.github.io/pyzed/es/integrations/architectures/):

- **`desktop.py`**: la ventana importa los modelos y usa el ORM directamente (mismo proceso).
- **`client.py`**: el sitio corre con `runserver` y la ventana llama a sus vistas JSON (cliente y servidor).

(Image: Una ventana de escritorio con encuestas de Django, resultados en barras y botones para votar)

## El proyecto de Django, como lo construye el tutorial

1. **Crea el proyecto y la app** (ya está hecho en la carpeta de la demo):

   y añade `"polls.apps.PollsConfig"` a `INSTALLED_APPS` en `mysite/settings.py`.

2. **Escribe los modelos** en `polls/models.py` (parte 2 del tutorial):

```python
class Question(models.Model):
    question_text = models.CharField(max_length=200)
    pub_date = models.DateTimeField("date published")

class Choice(models.Model):
    question = models.ForeignKey(Question, on_delete=models.CASCADE)
    choice_text = models.CharField(max_length=200)
    votes = models.IntegerField(default=0)
```

3. **Crea las tablas y un usuario administrador:**

4. **Registra el admin** (parte 7), con las opciones en línea, para poder añadir encuestas en
   [http://127.0.0.1:8000/admin/](http://127.0.0.1:8000/admin/).

5. **Escribe las vistas y las URLs** (partes 3 y 4): `IndexView`, `DetailView`, `ResultsView` y
   `vote`, que usa una expresión `F()` para que los votos simultáneos no se pisen. La demo añade
   dos pequeñas vistas JSON para el cliente de escritorio, `api/questions/` y
   `api/questions/<id>/vote/`.

6. **Ejecuta el sitio:**

   Las encuestas están en [http://127.0.0.1:8000/polls/](http://127.0.0.1:8000/polls/).

## Forma 1: el ORM en el mismo proceso

`desktop.py` configura Django igual que `manage.py` y luego importa los modelos. No hace falta
ningún servidor web; la ventana lee y escribe `db.sqlite3` a través del ORM.

```mermaid
flowchart LR
    subgraph p["uv run desktop.py"]
        W["PollsWindow"] -- "Command.perform" --> F["load · vote · create<br/>hilo de trabajo"]
        F --> ORM["ORM de Django<br/>polls.models"]
    end
    ORM --> DB[("db.sqlite3")]
    S["manage.py runserver<br/>(opcional, a la vez)"] --> DB
```

1. **Llama a `django.setup()` antes de importar los modelos:**

```python
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
django.setup()

from polls.models import Question
```

2. **Ejecuta las consultas del ORM en hilos de trabajo.** Son bloqueantes, así que
   `Command.perform` las ejecuta fuera del hilo de la UI. Django abre una conexión a la base de
   datos por hilo; cerrarlas al terminar evita que el pool de hilos acumule conexiones:

```python
def in_thread(function):
    @functools.wraps(function)
    def wrapper(*args, **kwargs):
        try:
            return function(*args, **kwargs)
        finally:
            connections.close_all()
    return wrapper

@in_thread
def vote(question_id, choice_id):
    choice = Question.objects.get(pk=question_id).choice_set.get(pk=choice_id)
    choice.votes = F("votes") + 1
    choice.save()
```

3. **Valida con las reglas del propio modelo.** `full_clean()` comprueba `max_length`, los campos
   obligatorios y los validadores propios; `pz.validation_errors` convierte el `ValidationError`
   de Django en `{campo: mensaje}` para `ui.field`:

```python
question = Question(question_text=draft["question_text"], pub_date=timezone.now())
try:
    question.full_clean()
except ValidationError as error:
    return pz.validation_errors(error)
with transaction.atomic():
    question.save()
    for text in draft["choices"]:
        question.choice_set.create(choice_text=text, votes=0)
```

**Tip:**

`runserver` puede correr a la vez sobre la misma base de datos. Un voto emitido en la ventana
aparece en `/polls/` y en el admin, y una pregunta añadida en el admin aparece en la ventana al
recargar.

## Forma 2: un cliente HTTP del sitio

`client.py` no importa Django en absoluto. Habla con las vistas JSON del sitio con httpx, así que
el sitio puede correr en este ordenador o en un servidor:

```mermaid
flowchart LR
    W["PollsWindow<br/>uv run client.py"] -- "httpx · JSON" --> V["polls/views.py<br/>api_questions · api_vote"]
    B["Navegador<br/>/polls/ · /admin/"] --> V2["Vistas del tutorial<br/>y admin"]
    subgraph s["uv run manage.py runserver"]
        V --> DB[("db.sqlite3")]
        V2 --> DB
    end
```

```python
async def vote(question_id, choice_id):
    async with httpx.AsyncClient(base_url=SITE, timeout=5) as client:
        response = await client.post(f"/polls/api/questions/{question_id}/vote/", json={"choice": choice_id})
        response.raise_for_status()
    return response.json()
```

**Caution:**

La vista JSON de votar está marcada con `@csrf_exempt` porque una app de escritorio no tiene
cookie CSRF. Para una API real, autentica al cliente con tokens, por ejemplo con
[Django REST framework](https://www.django-rest-framework.org/).

## Una ventana, dos backends

`PollsWindow` recibe `load`, `vote` y, opcionalmente, `create` como funciones normales.
`desktop.py` le pasa funciones normales (que corren en hilos) y `client.py` le pasa corrutinas
(que corren en el loop de asyncio); `Command.perform` acepta las dos, así que el código de la
ventana es el mismo.

## Código completo

**desktop.py:**
```py
"""Desktop app on Django's ORM: the models, validation and database of the site, with no web server.

    uv run manage.py migrate          # once, as in Django's tutorial
    uv run desktop.py

The window imports the polls app and queries it directly (architecture "same process").
`uv run manage.py runserver` can run at the same time on the same database: votes cast in
the window show up on /polls/ and in /admin/, and the other way round.
"""

import functools
import os

import django

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
django.setup()  # what manage.py does before anything touches the models

from django.core.exceptions import ValidationError  # noqa: E402
from django.db import connections, transaction  # noqa: E402
from django.db.models import F  # noqa: E402
from django.utils import timezone  # noqa: E402

import pyzed as pz  # noqa: E402

from polls.models import Question  # noqa: E402
from polls.views import question_json  # noqa: E402
from polls_window import PollsWindow  # noqa: E402

def in_thread(function):
    """Pyzed runs these in a worker thread; Django opens one connection per thread, so close it."""

    @functools.wraps(function)
    def wrapper(*args, **kwargs):
        try:
            return function(*args, **kwargs)
        finally:
            connections.close_all()

    return wrapper

@in_thread
def load():
    questions = Question.objects.filter(pub_date__lte=timezone.now()).order_by("-pub_date").prefetch_related("choice_set")
    return [question_json(q) for q in questions]

@in_thread
def vote(question_id, choice_id):
    choice = Question.objects.get(pk=question_id).choice_set.get(pk=choice_id)
    choice.votes = F("votes") + 1  # the database adds one, so two voters never overwrite each other
    choice.save()

@in_thread
def create(draft):
    question = Question(question_text=draft["question_text"], pub_date=timezone.now())
    try:
        question.full_clean()  # the model's own rules: required, max_length=200
        if len(draft["choices"]) < 2:
            raise ValidationError("A poll needs at least two choices.")
    except ValidationError as error:
        return pz.validation_errors(error)
    with transaction.atomic():
        question.save()
        for text in draft["choices"]:
            question.choice_set.create(choice_text=text, votes=0)
    return question_json(question)

if __name__ == "__main__":
    pz.run(PollsWindow(load, vote, create, mode="Django ORM, same process"))
```

**client.py:**
```py
"""Desktop client for the Django site over HTTP: the site runs on its own, the window uses its JSON API.

    uv run manage.py runserver        # terminal 1: the site (http://127.0.0.1:8000/polls/)
    uv run client.py                  # terminal 2: the window
    uv run client.py https://polls.example.com

Use this when the site is deployed on a server and the desktop app is one more client. The
window needs no Django and no database, only httpx.
"""

import sys

import httpx

import pyzed as pz

from polls_window import PollsWindow

SITE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8000"

async def load():
    try:
        async with httpx.AsyncClient(base_url=SITE, timeout=5) as client:
            response = await client.get("/polls/api/questions/")
            response.raise_for_status()
    except httpx.HTTPError as error:
        return f"Can't reach {SITE}: {error or type(error).__name__}"
    return response.json()["questions"]

async def vote(question_id, choice_id):
    try:
        async with httpx.AsyncClient(base_url=SITE, timeout=5) as client:
            response = await client.post(f"/polls/api/questions/{question_id}/vote/", json={"choice": choice_id})
            response.raise_for_status()
    except httpx.HTTPError as error:
        return f"The vote failed: {error or type(error).__name__}"
    return response.json()

if __name__ == "__main__":
    pz.run(PollsWindow(load, vote, mode=f"HTTP client of {SITE}"))
```

**polls_window.py:**
```py
"""The Pyzed window shared by desktop.py (Django ORM in this process) and client.py (HTTP to the site).

A backend is three functions returning plain dicts like ``polls.views.question_json``.
They may be regular functions (Pyzed runs them in a thread) or ``async def`` (run on
Pyzed's asyncio loop): ``Command.perform`` accepts both.
"""

from __future__ import annotations

from datetime import datetime, timedelta, timezone
from typing import Callable, Optional

import pyzed as pz
from pyzed import Command, ui

class PollsWindow(pz.App):
    title = "Polls · Django"
    size = (960, 600)

    def __init__(self, load: Callable, vote: Callable, create: Optional[Callable] = None, mode: str = ""):
        self.backend_load, self.backend_vote, self.backend_create = load, vote, create
        self.mode = mode
        self.questions: list[dict] = []
        self.selected_id: int | None = None
        self.message = ""
        self.creating = False
        self.draft = {"question_text": "", "choices": ["", "", ""]}
        self.errors: dict[str, str] = {}

    # -- backend calls ----------------------------------------------------------------------
    def on_start(self):
        return self.reload()

    def reload(self):
        return Command.perform(self.backend_load, self.loaded)

    def loaded(self, result):
        if isinstance(result, str):  # the backend couldn't answer
            self.message = result
            return
        self.questions, self.message = result, ""
        if self.questions and self.selected_id not in {q["id"] for q in self.questions}:
            self.selected_id = self.questions[0]["id"]

    def vote(self, question_id: int, choice_id: int):
        return Command.perform(lambda: self.backend_vote(question_id, choice_id), self.voted)

    def voted(self, result):
        if isinstance(result, str):
            self.message = result
            return None
        return self.reload()

    def create(self):
        draft = {"question_text": self.draft["question_text"], "choices": [c for c in self.draft["choices"] if c.strip()]}
        return Command.perform(lambda: self.backend_create(draft), self.created)

    def created(self, result):
        if "id" not in result:  # {field: message}
            self.errors = result
            return None
        self.creating, self.selected_id = False, result["id"]
        self.draft = {"question_text": "", "choices": ["", "", ""]}
        return self.reload()

    # -- view --------------------------------------------------------------------------------
    @staticmethod
    def recent(question: dict) -> bool:  # Question.was_published_recently, on the JSON
        published = datetime.fromisoformat(question["pub_date"])
        now = datetime.now(timezone.utc)
        return now - timedelta(days=1) <= published <= now

    def sidebar(self, t):
        rows = [ui.list_item(q["question_text"], lambda q=q: setattr(self, "selected_id", q["id"]), icon="vote",
                             end=ui.badge("new", tint="success") if self.recent(q) else None,
                             selected=q["id"] == self.selected_id, inset=True, height=30)
                for q in self.questions]
        new = ui.button("New question", lambda: setattr(self, "creating", True), icon="plus", full_width=True) \
            if self.backend_create else ui.muted("Add questions in the Django admin.", size="sm")
        return pz.container(pz.column([ui.muted("QUESTIONS", size="xs", weight="semibold"), *rows, pz.vspace(), new],
                                      spacing=2), padding=8, width=300, height="fill", bg=t.panel)

    def results(self):
        question = next((q for q in self.questions if q["id"] == self.selected_id), None)
        if question is None:
            return ui.empty_state("No polls are available.", icon="vote")
        total = sum(c["votes"] for c in question["choices"]) or 1
        rows = [pz.row([
            pz.container(ui.label(c["choice_text"]), width=200),
            pz.container(ui.progress(c["votes"] / total, height=8), width="fill"),
            pz.container(ui.muted(f"{c['votes']} vote{'s' if c['votes'] != 1 else ''}", size="sm"), width=70, align_x="end"),
            ui.button("Vote", lambda c=c: self.vote(question["id"], c["id"]), size="sm"),
        ], spacing=12, align="center") for c in question["choices"]]
        return pz.column([ui.heading(question["question_text"], level=2),
                          ui.muted(f"Published {question['pub_date'][:16].replace('T', ' ')}", size="sm"),
                          *rows], spacing=14, padding=24)

    def dialog(self):
        if not self.creating:
            return None
        choices = self.draft["choices"]

        def set_choice(i):
            return lambda value: choices.__setitem__(i, value)

        form = pz.column([
            ui.field("Question", ui.input(self.draft["question_text"], lambda v: self.draft.__setitem__("question_text", v),
                                          placeholder="What's new?", width=260), error=self.errors.get("question_text")),
            *[ui.field(f"Choice {i + 1}", ui.input(text, set_choice(i), width=260)) for i, text in enumerate(choices)],
            ui.callout(self.errors[""], tint="error") if self.errors.get("") else None,
        ], spacing=12)
        close = lambda: setattr(self, "creating", False)  # noqa: E731
        return ui.modal(form, title="New question", on_close=close, width=520,
                        footer=[ui.button("Cancel", close, variant="ghost"),
                                ui.button("Publish", self.create, icon="check", variant="solid")])

    def view(self):
        t = self.resolved_theme()
        status = ui.status_bar([ui.status_item(self.message or self.mode, icon="circle-alert" if self.message else "database",
                                               icon_color="error" if self.message else "success")])
        page = pz.column([pz.row([self.sidebar(t), pz.container(self.results(), width="fill", height="fill", bg=t.editor)]),
                          status])
        return ui.layer(page, self.dialog(), on_dismiss=lambda: setattr(self, "creating", False), backdrop="#00000066")
```

**polls/views.py:**
```py
"""The views from Django's tutorial (parts 3 and 4), plus a small JSON API for the desktop client.

Tutorial: https://docs.djangoproject.com/en/stable/intro/tutorial04/
"""

import json

from django.db.models import F
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils import timezone
from django.views import generic
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_POST

from .models import Choice, Question

class IndexView(generic.ListView):
    template_name = "polls/index.html"
    context_object_name = "latest_question_list"

    def get_queryset(self):
        """Return the last five published questions (not including those set to be published in the future)."""
        return Question.objects.filter(pub_date__lte=timezone.now()).order_by("-pub_date")[:5]

class DetailView(generic.DetailView):
    model = Question
    template_name = "polls/detail.html"

    def get_queryset(self):
        """Excludes any questions that aren't published yet."""
        return Question.objects.filter(pub_date__lte=timezone.now())

class ResultsView(generic.DetailView):
    model = Question
    template_name = "polls/results.html"

def vote(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    try:
        selected_choice = question.choice_set.get(pk=request.POST["choice"])
    except (KeyError, Choice.DoesNotExist):
        # Redisplay the question voting form.
        return render(request, "polls/detail.html", {"question": question, "error_message": "You didn't select a choice."})
    else:
        selected_choice.votes = F("votes") + 1
        selected_choice.save()
        # Always return an HttpResponseRedirect after successfully dealing with POST data.
        return HttpResponseRedirect(reverse("polls:results", args=(question.id,)))

# -- JSON API for desktop clients (client.py) ---------------------------------------------------

def question_json(question):
    return {
        "id": question.id,
        "question_text": question.question_text,
        "pub_date": question.pub_date.isoformat(),
        "choices": [{"id": c.id, "choice_text": c.choice_text, "votes": c.votes} for c in question.choice_set.all()],
    }

@require_GET
def api_questions(request):
    questions = Question.objects.filter(pub_date__lte=timezone.now()).order_by("-pub_date").prefetch_related("choice_set")
    return JsonResponse({"questions": [question_json(q) for q in questions]})

@csrf_exempt  # a desktop app has no CSRF cookie; protect a real API with tokens instead (e.g. Django REST framework)
@require_POST
def api_vote(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    try:
        choice_id = json.loads(request.body)["choice"]
        selected_choice = question.choice_set.get(pk=choice_id)
    except (KeyError, ValueError, Choice.DoesNotExist):
        return JsonResponse({"detail": "You didn't select a choice."}, status=400)
    selected_choice.votes = F("votes") + 1
    selected_choice.save()
    return JsonResponse(question_json(question))
```
