Posts mit dem Label Bottle werden angezeigt. Alle Posts anzeigen
Posts mit dem Label Bottle werden angezeigt. Alle Posts anzeigen

Samstag, 30. Januar 2016

Python, WTForms, JavaScript: Formulare dynamisch erweitern

Folgender Fall: in einer Webanwendung sollen Daten über ein Formular eingegeben werden. Die Felder des Formulars sind klar definiert, aber wie viele Daten eingegeben werden sollen, ist offen.

Ein Beispiel hierfür wäre z.B. eine Webanwendung für Rezepte. Es gibt Rezepte, die haben eine handvoll Zutaten. Aber es gibt auch Rezepte, die haben mehrere Dutzend Zutaten. Hier macht es also wenig Sinn, die Anzahl der Eingabefelder für das Formular fix festzulegen. Vielmehr gilt es, die Möglichkeit für den Nutzer zu schaffen, das Formular dynamisch zu erweitern.

Die Zutaten hier dafür: Python 3.4, Bottle 0.12, WTForms 2.1 , JavaScript und jQuery 2.2. Bottle als Webframework ist hier übrigens nur Mittel zum Zweck, das Beispiel lässt sich genau so mit anderen Python Webframeworks umsetzen.

Das Beispiel besteht aus vier Codeteilen:
  • der Bottle Hauptanwendung, die das Routing etc. festlegt
  • den Formularklassen, erstellt mit WTForms
  • den beiden Templates zur Dateneingabe und zur Datenausgabe
Wer das Beispiel nachstellen möchte: wie bei Bottle üblich wird erwartet, dass die Templates im Verzeichnis views liegen. Das benötigte jQuery wird im Verzeichnis static erwartet. Alternativ könnte man natürlich auch jQuery von einem der vielen CDN-Servern online laden.

Die Hauptdatei sieht so aus:

import os
from bottle import route, run, template, debug, static_file, request
from my_forms import InputForm

BASE_DIR = os.path.dirname(os.path.dirname(__file__))

@route('/ingredients')
def index():
    form = InputForm()
    return template('dyn_form_input_wt.html', form=form)

@route('/ingredients', method='POST')
def post_data():
    form_data =  request.forms
    form = InputForm(form_data)
    if not form.validate():
        errors = form.errors['ingredients']
    else:
        errors = None
    ingredients = []
    for i in range(0, int(len(form_data)/3)):
        ingredients.append((form_data['ingredients-'+str(i)+'-description'],
                            form_data['ingredients-'+str(i)+'-unit'],
                            form_data['ingredients-'+str(i)+'-quantity']))
    return template('output.html',
                    ingredients=ingredients,
                    errors=errors)

@route('/static/<filename>')
def server_static(filename):
    return static_file(filename, root=os.path.join(BASE_DIR, 'static'))

debug=True
run(host='localhost', port=8080, reloader=True)

Hier gibt es eigentlich nicht viel zu sagen. Eine Route liefert das Formular aus, die Route mit method='POST' nimmt die Formulardaten auf und sortiert diese zurück in eine Liste. In einer realen Anwendung würde man an dieser Stelle die Daten z.B. in eine Datenbank schreiben.
Wichtig ist hierbei aber, dass davon ausgegangen wird, dass die name-Attribute der Formularfelder a) der Namensgebung von WTForms FieldList entsprechen - also Name des Formulars - Zähler - Name des Felds - , b) der Zähler bei Null beginnt und c) die Zählung lückenlos ist (also 0, 1, 2, 3 usw.). Dass das tatsächlich auch so ist, dafür sorgt der JavaScript in der weiter unten gezeigten Template-Datei dyn_form_input_wt.html.

Die Datei my_forms.py, welche die Formularklassen enthält, sieht so aus:

from wtforms.form import Form
from wtforms.fields import StringField, SelectField, DecimalField, FormField, \
    FieldList
from wtforms.validators import NumberRange, InputRequired

UNITS = [('kg', 'kg'),
         ('g', 'g'),
         ('TL', 'TL'),
         ('EL', 'EL'),
         ('St', 'St')] 

class IngredientForm(Form):
    description = StringField('description',
                              [InputRequired()])                                  
    unit = SelectField('unit', choices=UNITS)
    quantity = DecimalField('quantity',
                            [InputRequired(), NumberRange(min=0)],
                             places=3)
                            
class InputForm(Form):
    ingredients = FieldList(FormField(IngredientForm), min_entries=1)

Das ist soweit alles WTForms Standard ohne Tricks und Kniffe. In der Klasse InputForm wird die IngredientForm per FormField zu einem Formular-Feld zusammengefasst, FieldList wiederum fasst mehrere Formular-Felder zusammen. Mehr Infos dazu sind in der Dokumentation von WTForms zu finden.

Das Template dyn_form_input_wt.html ist für die Eingabe der Zutaten sowie das dynamische Hinzufügen und Entfernen von Eingabefeldern zuständig. Das Template sieht so aus:

<!DOCTYPE html>
<html>
<head>
    <title>Dynamic Forms</title>
    <script src="static/jquery-2.2.0.min.js"></script>
</head>
<body>
<form action="/ingredients" method="POST">
    <div class="input_fields_wrap">
    <!-- here goes the form fields -->
    </div>
    <button class="add_field_button">Add field</button>
    <button class="remove_field_button">Remove field</button>
    <input type="submit">
</form>
<script>
$(document).ready(function() {
    var max_fields = 20; //maximum input boxes allowed
    var wrapper = $(".input_fields_wrap"); //Fields wrapper
    var addButton = $(".add_field_button"); //Add button ID
    var removeButton = $(".remove_field_button"); //Add button ID
    var htmlString = '<div id="input_fields_0">{{ !form.ingredients }}</div>';
    var field_counter = 0;
    $(wrapper).append(makeString()); //add the first form
    $(addButton).click(function(e){ //on add input button click
        e.preventDefault();
        if(field_counter < max_fields){ //max input box allowed
            field_counter++;
            $(wrapper).append(makeString());}
        else { window.alert('max number of ingredients reached!')}
    });
    $(removeButton).click(function(e){ //on remove input button click
        e.preventDefault();
        if(field_counter > 0){ //make sure at least one field is there
            $('#input_fields_'+field_counter).remove();
            field_counter--;}
        else { window.alert('Cannot delete, one input has to remain.')}
    });
    function makeString() {
        var myString = htmlString;
        return myString.replace(/0/g, field_counter);
    };
});
</script>
</body>
</html>

Wie zu sehen ist, besteht das Template aus zwei Sektionen: dem HTML-Teil und dem JavaScript Teil. Erster ist ziemlich "straight forward" und bedarf wohl keiner weiterer Erklärung, letzter macht die Hauptarbeit und liefert die Dynamik.

Im HTML-Teil wird nur der "Rahmen" für das Formular angelegt, es werden aber keine Formularfelder erzeugt. Dies geschieht ausschließlich per JavaScript / jQuery.

Den Ausgangspunkt bildet die Definition der Variablen htmlString, in der auch das WTForms Formular gerendert wird, über {{ !form.ingredients }}

Die Zeile $(wrapper).append(makeString()); erzeugt den ersten Satz Formularfelder, der Zähler field_counter steht hier noch auf Null.

Zum Hinzufügen weitere Felder ist eine JavaScript-Funktion an den Button "Add field" gebunden, welche a) checkt, ob nicht die per max_fields festgelegte Anzahl an Feldern schon erreicht ist, b) den Feldzähler field_counter um eins erhöht und dann c) einen Satz Formularfelder hinzufügt.

Zum Löschen, welches über eine an den Button "Remove Fields" gebundene Funktion erfolgt, ist der Ablauf ähnlich. Hier wird zuerst geprüft, ob mehr als ein Satz Formularfelder vorhanden ist. Wenn ja wird das letzte entfernt und der field_counter Zähler um eins dekrementiert.

Die Funktion makeString ist dafür verantwortlich, dass die Nummerierung der Formularfelder (bzw. genau genommen deren Attribute wie id, name etc. gem. dem aktuellen Stand von field_counter angepasst werden. Dazu wird einfach die String-Methode replace() mit den entsprechenden Werten auf den Ausgangsstring angewendet und als neuer String zurück geliefert.

Wenn das Formular abgesendet wurde, wird in diesem Beispiel hier das Template output.html aufgerufen, welches die eine Liste der Zutaten mit Menge und Einheit sowie mögliche Fehler ausgibt. Das Template sieht so aus:

<!DOCTYPE html>
<html>
<head>
<title>Dynamic Forms Output</title>
</head>
<body>
<p>Received ingredients from form data:</p>
<ul>
% for ingredient in ingredients:
<li>{{ ingredient[0] }}: {{ ingredient[2] }} {{ ingredient[1] }}
% end
</ul>
% if errors:
<p>The form has the following errors:
<ul>
% for error in errors:
<li>{{ error }}
% end
</ul>
% end
</body>
</html>

Das Template ist hier auch nur Mittel zum Zweck, bei Produktiv-Code würde man hier sicherlich anders vorgehen.

Wie hier gezeigt wurde, ist es nicht so schwierig und aufwendig, dynamisch erweiterbare Formular zu erzeugen, auch wenn diese in den ansonsten "starren" Klassen eines Python Formularframeworks definiert sind.
Dazu benötigt werden aus WTForms insbesonders die Klassen FormField und FieldList sowie auf der Client-Seite ein bisschen JavaScript mit jQuery.


Samstag, 5. November 2011

Bottle, WTForms und Datei-Uploads

Wer Webanwendung schreibt kommt wahrscheinlich auch irgendwann mal an den Punkt, wo dem Anwender die Möglichkeit gegeben wird, Dateien auf den Server hoch zuladen. Natürlich bietet mein derzeit favorisiertes WSGI-Framework Bottle auch Funktionen an, um dies zu realisieren. Ebenso bietet mein derzeit favorisiertes HTML Form Framework WTForms eine entsprechende Klasse an. Um die "Hochzeit" der beiden geht es in diesem Blogeintrag.

Letztendlich ist das alles kein großes Ding - die jeweiligen Dokumentationen (Link und Link) sind zwar kurz, aber aussagekräftig. Aber ich kann ja trotzdem ein paar Stolpersteine aus dem Weg räumen.

Dazu im folgenden eine kleine Beispielapplikation. Diese besteht aus drei Dateien:
  • myapp.py, welche die eigentliche Applikation ist
  • forms.py, welche die Definition der Formularklasse enthält
  • formtemplate.tpl, welche das Template für die Formulardarstellung enthält
Die Datei "forms.py" sieht dabei so aus:

# -*- coding: utf-8 -*-

import re
from wtforms import Form, FileField, TextField, SubmitField,\     validators

class AttachmentForm(Form):
    attachment = FileField(u'Datei',
        [validators.regexp(r'.+\.(jpg|pdf|png|docx|doc|txt)$',
        flags=2,
        message=u'Es sind nur Dateien mit der Endung jpg, pdf,\
        png, docx, doc und txt erlaubt!')])
    textfield = TextField(u'Bemerkung',
            [validators.Required(
             message=u'Es muss ein Text eingegeben werden!'),
             validators.Length(max=50,
             message=u'Der Text darf nicht länger als 50 Zeichen\
             sein.')])
    send = SubmitField(u'Senden')

    def validate_attachment(form, field):
        if field.data:
            field.data = re.sub(
                r'[^a-zA-Z0-9_.-]', '_', field.data)

Der Regex-Validator kann natürlich nach belieben geändert (oder weg gelassen) werden. Gleiches gilt für das Textfeld namens "textfield", welches hier jedoch verwendet wird, um später den Unterschied Formulardaten und Upload-Daten deutlich zu machen (siehe unten). Der Validator "validate_attachment" ist auch optional - aber hier durchaus sinnvoll. Es validiert nämlich nicht, sondern ersetzt einfach alle (Sonder-) Zeichen im Dateinamen, welche später Probleme machen könnten, durch einen Unterstrich.

Das Template "formtemplate.tpl" sieht so aus:

<html>
<head>
<title>Datei-Upload</title>
</head>
<body>
<h2>Datei-Upload</h2>
%if errors:
<p class="fehler">Die Eingabe enthält <a href="#fehler">Fehler</a></p>
%end
<form action="/upload" method="post" enctype="multipart/form-data">
<ul>
<li>{{!form.attachment.label()}}<br/> {{!form.attachment(size=75)}}</li>
<br/>
<li>{{!form.textfield.label()}}<br/> {{!form.textfield(size=50)}}</li>
</ul>
<p>{{!form.send()}}</p>
</form>
<p>Hinweis: Alle Zeichen im Dateinamen, welche nicht im Bereich a-z, A-Z, 0-9, _, - und . enthalten sind, werden durch einen Unterstrich _ ausgetauscht. Dies betrifft also auch die Zeichen ä, ö, ü und ß sowie das Leerzeichen.</p>
%if errors:
<p style="color: #ff0000;">Fehler:</p>
%for k,v in errors.iteritems():
<p>{{!form[k].label}}: {{!v[0]}}</p>
%end
%end
</body>
</html>

Auch hier gibt es nicht außergewöhnliches. Es gibt ein rudimentäres HTML-Gerüst, das Formular an sich inklusive Hinweistext und die Anzeige von Fehlern im Formular, sofern dieses Fehler hat.

Die Hauptdatei "myapp.py" sieht so aus:

#!/usr/bin/env python

import os
from bottle import route, template, request, run, debug
import forms

PATH = '/home/noisefloor/attachment'

@route('/upload')
@route('/upload', method='POST')
def upload():
    req = request.forms
    data = request.files.get('attachment')
    form = forms.AttachmentForm(req)
    try:
        form.attachment.data = data.filename
    except:
        form.attachment.data = None
    if not req or not form.validate():
        return template('formtemplate.tpl',
            form=form,errors=form.errors)
    else:
        raw = data.file.read()
        with open(os.path.join(PATH, data.filename),'wb') as f:
            f.write(raw)
        return u'Datei {0} gespeichert, Bemerkung: {1}'.format(
            data.filename, form.textfield.data)

if __name__ == '__main__':
    debug(True)
    run(reloader=True)

Ein paar Anmerkungen sind hier zu machen:

Bottle hält die Daten des Dateiuploads unter request.files vor, während die Formulardaten über request.forms abrufbar sind. Dabei ist zu beachten, dass auch der Dateiname der herauf geladenen Datei nicht in request.forms liegt, sondern in ebenfalls in request.files. Es ist in der Tat nur der eigentliche Name der Datei dort hinterlegt, ohne den (lokalen) Dateipfad, von der die Datei hoch geladen wurde.

Von daher ist auch die Zeile

form.attachment.data = data.filename

wichtig, da so der Dateiname in die Formulardaten geschrieben wird. Ohne diesen Schritt würde das Formular nie validieren, da "attachment" ein Pflichtfeld ist.

Werden die Formular- und Uploaddaten übermittelt und Validiert das Formular, dann wird die Datei lokal gespeichert. Wichtig ist hier, dass der Benutzer, unter dem die Anwendung läuft, auch Schreibrechte in dem entsprechenden Verzeichnis hat.

In einer realen Anwendung würde man die Zeile

raw = data.file.read()

vielleicht nicht alleine so verwenden, da der Upload von sehr großen Dateien zu Problemen wie z.B. einem komplett vollem RAM führen kann. Von daher wird in der Bottle-Dokumentation ebenfalls vor dieser Zeile gewarnt.

In der Beispielanwendung wird das Feld "textfield" nicht weiter verwendet. In einer realen Applikation würde man diese z.B. zusammen mit dem Dateinamen in einer Datenbank speichern.

Zusammenfassend kann gesagt werden, dass die Kombination von Bottle und WTForms auch für Dateiuploads nicht weiter schwierig, sofern man einige wenige Punkte beachtet.

Montag, 15. August 2011

Bottle: Dateinamen für dynamisch generierte PDFs übertragen

Ich habe diverse Python-basierte Webapplikation für das Firmenintranet programmiert, die PDFs (mittels ReportLab) generieren. Die PDFs schreiben ich dabei in ein StringIO-Objekt anstatt die Datei temporär auf Platte zu legen. Funktioniert auch alles soweit gut.

Das einzige "Problem" war, dass der Name des PDF nicht übertragen wurde, d.h. wenn man die Datei im Browser öffnet und dann speichern will schlagen Evince, Adobe Reader & Co. als Dateiname immer "pdf.pdf" (oder so ähnlich) vor. Nun gut, nicht wirklich tragisch, aber ein bisschen doof ist das trotzdem.

Die Lösung für das Problem ist dabei aber denkbar einfach - man muss nur von Hand den passende Eintrag im Response-Header setzen. Im Falle von Bottle, meinem aktuell favorisierten Webframework, sieht das so aus:


from bottle import response, ...
...
#Buffer-Objekt anlegen
buf = cStringIO.StringIO()
#hier kommt der Pythoncode zur PDF Generierung
...
#Senden vorbereiten
response.headers['Content-Type'] = 'application/pdf'
response.headers['Content-Disposition'] = 'attachment;filename={0}'.format(pdf_filename)
#und Senden
return  buf

Wie gesagt, sehr einfach. "pdf_filename" enthält dabei den eigentlichen Dateinamen. Funktioniert natürlich auch genau so mit allen anderen Dateitypen.

Donnerstag, 9. Juni 2011

Bottle und statischer Inhalt - Verwendung von static_file

Der der Verwendung von in Python geschriebenen WSGI-basieren Webapplikationen kommt es immer wieder zu Verwirrungen, wenn dynamisch generierter Inhalt mit statischem (wie Bilder, CSS-Dateien) gemischt werden soll.

Dies oft dadurch bedingt, dass man beim Programmieren noch das "klassische" HTML und die zugrunde liegende Datei- / Verzeichnisstruktur im Kopf hat. Nur: WSGI-Applikation können im Server grundsätzlich an einen beliebigen Pfad ("Route") gebunden werden, der vom Speicherort der Datei(en) auf der Festplatte unabhängig ist.

Um nun statischen Content auszuliefern gibt es zwei Möglichkeiten: entweder man holt diesen von einer anderen URL, die außerhalb der Routen der Applikation liegen oder - geschickter - man definiert eine Route, welche für das Ausliefern zuständig ist.

Verwendet man das Bottle Microframework so kann eine Lösung wie folgt aussehen:

Applikation, "test_static.py":
#!/usr/bin/python
# -*- coding: utf-8 -*-

from bottle import route, static_file, template, run, debug

@route('/test')
def test():
return template('test.tpl')

@route('/test/static/:filename')
def static(filename):
rootpath = '/home/jochen/code/bottle_test/static/'
return static_file(filename,root=rootpath)

if __name__ == "__main__":
debug(True)
run(reloader=True)


Das Template "test.tpl" sieht so aus:
<html>
<head>
<title>Bottle test</title>
<link rel="stylesheet" type="text/css" href="/test/static/format.css">
</head>
<body>
<h1>Hallo Welt</h1>
<p>Wie geht's</p>
<p class="kursiv">Ich hoffe gut!</p>
</p>
</body>
</html>


Und die CSS-Datei "format.css" so:
.kursiv {font-style: italic; color: red; }


So weit, so gut. Prinzipiell sollte sich das Beispiel auch auf andere Framework übertragen lassen.

Zwei Dinge sind dabei essentiell wichtig: Erstens, dass bei der Option "root" von "static_file" der absolute Pfad zu den statischen Dateien im Dateisystem angegeben wird. Zweitens, dass in der Template-Datei die korrekte Route bezogen auf die Applikation angegeben wird. Dann sollte die Applikation auch statische Inhalte problemlos ausliefern können.

Hinweis: getestet mit Python 2.6. und Bottle 0.8.