RkBlog

Hardware, programming and astronomy tutorials and reviews.

Widok filtrujący w Django

Przykład rozbudowanego widoku do filtrowania i listowania dużych kolekcji danych po wielu parametrach w Django. Artykuł zawiera przykładowy model, kod widoku i szablonu obsługującego filtrowanie.

Naszym celem będzie stworzenie widoku umożliwiającego filtrowanie wpisów z danego modelu-tabeli za pomocą formularza umożliwiającego określenie wielu parametrów :) Dodatkowo zastosujemy generyczny widok do stronicowania (tak by nie był to zwykły formularz i POST) (zobacz zrzuty ekranu na końcu artykułu)

Jako przykład wykorzystam moją aplikację - katalog postaci Baldurs Gate. Model ma wiele pól, po których można filtrować:
class Character(models.Model):
	chr_file = models.FileField(upload_to='bgate')
	name = models.CharField(maxlength=100)
	xp = models.PositiveIntegerField()
	hp = models.PositiveIntegerField()
	avatar = models.CharField(maxlength=100)
	strength = models.PositiveSmallIntegerField()
	inteligence = models.PositiveSmallIntegerField()
	wisdom = models.PositiveSmallIntegerField()
	charisma = models.PositiveSmallIntegerField()
	constitution = models.PositiveSmallIntegerField()
	dex = models.PositiveSmallIntegerField()
	race = models.CharField(maxlength=50)
	main_class = models.CharField(maxlength=50)
	gender = models.CharField(maxlength=50)
	attacks = models.PositiveSmallIntegerField()
	death_save = models.PositiveSmallIntegerField()
	wands_save = models.PositiveSmallIntegerField()
	polymorph_save = models.PositiveSmallIntegerField()
	breath_save = models.PositiveSmallIntegerField()
	spells_save = models.PositiveSmallIntegerField()
	fire = models.PositiveIntegerField()
	cold = models.PositiveIntegerField()
	elect = models.PositiveIntegerField()
	acid = models.PositiveIntegerField()
	magic = models.PositiveIntegerField()
	magic_fire = models.PositiveIntegerField()
	magic_cold = models.PositiveIntegerField()
	slash = models.PositiveIntegerField()
	pierce = models.PositiveIntegerField()
	blunt = models.PositiveIntegerField()
	missile = models.PositiveIntegerField()
	thaco = models.IntegerField()
	levels = models.PositiveIntegerField()
	kit = models.CharField(maxlength=255)
	alingment = models.CharField(maxlength=255)
	author = models.ForeignKey(User)
	comment = models.TextField(maxlength=500)
	class Meta:
		verbose_name = _('Baldurs Gate Character')
		verbose_name_plural = _('Baldurs Gate Characters')
		#db_table = 'rk_baldur' + str(settings.SITE_ID)
		db_table = 'rk_baldur5'
	def get_absolute_url(self):
		return '/bgate/show/' + str(self.id) + '/'
	class Admin:
		list_display = ('name', 'author')
		search_fields = ['name', 'author']
	def __str__(self):
		return self.name
Startowy widok to rozszerzony generyczny widok:
def chr_filter(request, pagination_id=1):
	from django.views.generic.list_detail import object_list
	return object_list(request, Character.objects.all().order_by('-id'), paginate_by = 15, allow_empty = True, page = pagination_id, template_name = 'baldur/chr_filter.html')
A szablon, najważniejsza jego część wygląda tak:
<h2 class="pageh">{% trans "Baldurs Gate Characters" %}</h2>
<div class="content"><table><tr>
{% for t in object_list %}
<td><a href="/bgate/show/{{ t.id }}/" style="text-decoration:none;">{% if t.avatar %}<img src="/site_media/bgate_av/{{ t.avatar }}.jpg" alt="avatar" border="0" /><br />{% endif %}<strong>{{ t.name }}</strong><br />{{ t.main_class}} ({{ t.levels }} {% trans "level" %})</a>
{% cycle </td>,</td>,</td>,</td>,</td></tr><tr> %}
{% endfor %}
</tr></table></div>
<div class="box" style="text-align:center;">
{% if has_previous %}
<h3><a href="/bgate/list/{{ previous }}/">{% trans "Previous Page" %}</a></h3>
{% endif %}
{% if has_next %}
<h3><a href="/bgate/list/{{ next }}/">{% trans "Next Page" %}</a></h3>
{% endif %}
</div>
A URL do widoku to /bgate/filter/

Tworzenie filtra

Tworzenie filtra zaczynamy od przygotowania formularza zawierającego pola, po których chcemy filtrować. Ja wybrałem pola "race" - Rasa, "class" - Klasa, "gender" - Płeć i "level" - Poziom postaci. Poza liczbowym poziomem pozostałe pola zawierają łańcuchy. Oto formularz:
<form method="post" action="." onsubmit="location.assign('/bgate/filter/' + document.szukaj.race.value + '/' + document.szukaj.klass.value + '/' + document.szukaj.gender.value + '/' + document.szukaj.level.value + '/'); return false;" name="szukaj">
<div class="content">
          <table>
           <tr class="rowA">
              <td class="first"><b>{% trans "Race" %}</b></td>
              <td><b>{% trans "Class" %}</b></td>
              <td><b>{% trans "Gender" %}</b></td>
              <td><b>{% trans "Levels" %}</b></td>
		<td> </td>
            </tr>
            <tr class="rowB">
              <td class="first"><select name="race">
              <option value="0">{% trans "All" %}</option>
               {% for i in races %}
               <option>{{ i }}</option>
               {% endfor %}
              </select></td>
              <td><select name="klass">
              <option value="0">{% trans "All" %}</option>
               {% for i in classes %}
                <option>{{ i }}</option>
               {% endfor %}
              </select></td>
              <td><select name="gender">
              <option value="0">{% trans "All" %}</option>
               {% for i in genders %}
                <option>{{ i }}</option>
               {% endfor %}
              </select></td>
              <td><select name="level">
              <option value="0">{% trans "All" %}</option>
              <option value="1">1-2</option>
              <option value="2">1-8</option>
              <option value="3">8-14</option>
              <option value="4">14-20</option>
              <option value="5">20+</option>
              </select></td>
              <td><input type="submit" name="search" class="button" value="{% trans "Search" %}" /></td>
            </tr>
</table></div></form>
Najważniejszy jest atrybut taga form:
onsubmit="location.assign('/bgate/filter/' + document.szukaj.race.value + '/' + document.szukaj.klass.value + '/' + document.szukaj.gender.value + '/' + document.szukaj.level.value + '/'); return false;"
Formularz nie jest wysyłany ("return false;") lecz JavaScript przenosi nas pod inny odnośnik wykorzystując wartości z formularza:
document.NAZWA_FORMULARZA.NAZWA_POLA.value
Dlaczego tak a nie użyć np. QueryString i zmiennych GET ? Dostajemy niezbyt ładne linki oraz nie mamy walidacji danych w GET, a powyższe rozwiązanie umożliwia dokładne określenie typów wartości jakie poszczególne zmienne mogą przyjmować. Reszta formularza to zwykłe pola Select. Poza poziomami listuję wszystkie możliwe rasy/klasy i płcie. Dla Poziomów zastosowałem własne "kryteria" podając przedziały poziomów i przypisując im określone wartości liczbowe. Mogłem dodać wszystkie opcje bezpośrednio do szablonu ale nie chciałem robić go nieczytelnym. Dodatkowo jak widać wartość "0" dla pola oznacza "Dowolny" - nie ma filtrowania dla danego pola. Widok przekazuje odpowiednie dane:
def chr_filter(request, pagination_id=1):
	from django.views.generic.list_detail import object_list
	characters = Character.objects.all().order_by('-id')
	races = [_('Human'), _('Elf'), _('Half elf'), _('Dwarf'), _('Halfling'), _('Gnome'), _('Half Orc')]
	genders = [_('Male'), _('Female')]
	#let say that there is no / in those strings
	classes = [_('Mage'), _('Fighter'), _('Cleric'), _('Thief'), _('Bard'), _('Paladin'), _('Fighter/Mage'), _('Fighter/Cleric'), _('Fighter/Thief'), _('Fighter/Mage/Thief'), _('Druid'), _('Ranger'), _('Mage/Thief'), _('Cleric/Mage'), _('Cleric/Thief'), _('Fighter/Druid'), _('Fighter/Mage/Cleric'), _('Cleric/Ranger'), _('Sorcerer'), _('Monk')]
	return object_list(request, characters, paginate_by = 15, allow_empty = True, page = pagination_id, template_name = 'baldur/chr_filter.html', extra_context = {'races': races, 'genders': genders, 'classes': classes})
Po "wysłaniu" formularza mogę mieć URL w postaci /bgate/filter/Elf/Mag/Kobieta/0/. Trzeba dodać odpowiednie reguły URLi
(r'^filter/$', 'views.chr_filter'),
(r'^filter/(?P<race>[\w\-_łó ]+)/(?P<klass>[\w\-_łŁ]+)/(?P<gender>[\w\-_ęż]+)/(?P<level>[0-9]+)/$', 'views.chr_filter'),
(r'^filter/(?P<race>[\w\-_łó ]+)/(?P<klass>[\w\-_łŁ]+)/(?P<gender>[\w\-_ęż]+)/(?P<level>[0-9]+)/(?P<pagination_id>(\d+))/$', 'views.chr_filter'),
Trzy zmienne tekstowe i jedna liczbowa. Dla zmiennych tekstowych pojawiają się polskie znaki - niektóre z dostępnych wartości je posiadają (warto unikać takich zmiennych w linku). Teraz mapper URLi przpisze odnośniki typu /bgate/filter/Elf/Mag/Kobieta/0/ do widoku przekazując odpowiednie zmienne:
def chr_filter(request, pagination_id=1, race='0', klass='0', gender='0', level='0'):
	from django.views.generic.list_detail import object_list
	characters = Character.objects.all().order_by('-id')
	
	if race != '0':
		characters = characters.filter(race=race)
	if klass != '0':
		characters = characters.filter(main_class=klass)
	if gender != '0':
		characters = characters.filter(gender=gender)
	
	if level == '1':
		characters = characters.filter(levels__range=(1, 2))
	if level == '2':
		characters = characters.filter(levels__range=(1, 8))
	if level == '3':
		characters = characters.filter(levels__range=(8, 14))
	if level == '4':
		characters = characters.filter(levels__range=(14, 20))
	if level == '5':
		characters = characters.filter(levels__gte=20)
	
	races = [_('Human'), _('Elf'), _('Half elf'), _('Dwarf'), _('Halfling'), _('Gnome'), _('Half Orc')]
	genders = [_('Male'), _('Female')]
	classes = [_('Mage'), _('Fighter'), _('Cleric'), _('Thief'), _('Bard'), _('Paladin'), _('Fighter/Mage'), _('Fighter/Cleric'), _('Fighter/Thief'), _('Fighter/Mage/Thief'), _('Druid'), _('Ranger'), _('Mage/Thief'), _('Cleric/Mage'), _('Cleric/Thief'), _('Fighter/Druid'), _('Fighter/Mage/Cleric'), _('Cleric/Ranger'), _('Sorcerer'), _('Monk')]
	return object_list(request, characters, paginate_by = 15, allow_empty = True, page = pagination_id, template_name = 'baldur/chr_filter.html', extra_context = {'races': races, 'genders': genders, 'classes': classes})
Dodałem do funkcji widoku spodziewane zmienne przypisując im domyślną wartość: , race='0', klass='0', gender='0', level='0' ("0" oznacza dowolną wartość, takie mam założenie). Następnie w kodzie pobieram wszystkie postacie (zapytanie nie wykona się dopóki nie odwołam się do wartości - czyli dopiero przy wykonaniu generycznego widoku): characters = Character.objects.all().order_by('-id'), a następnie filtruję wynik jeżeli zmienne mają wartość różną od "0". Zwróć uwagę że wartości z pola "level" nie będą liczbami tylko łańcuchami. __range oznacza że wartość musi być w przedziale od-do, a __gte wartości większe lub równe podanej wartości. Wykorzystaliśmy tutaj zaletę ORMa - możliwość stopniowego i dowolnego nakładania reguł filtrowania. Nie trzeba kombinować jak to wepchnąć do jednej reguły.

Nasz widok i filtrowanie działa, ale trzeba wykończyć kod - dodać zapamiętywanie wartości przez formularz oraz dodać linki dla stronicowania. W tym celu do szablonu trzeba przekazać wartości zmiennych:
return object_list(request, characters, paginate_by = 15, allow_empty = True, page = pagination_id, template_name = 'baldur/chr_filter.html', extra_context = {'races': races, 'genders': genders, 'classes': classes, 'race': race, 'klass': klass, 'gender': gender, 'level': level})
Dzięki czemu w formularzu możemy zastosować sztuczkę typu:
<option{% ifequal i race %} selected="selected"{% endifequal %}>{{ i }}</option>
Formularz wygląda tak:
<form method="post" action="." onsubmit="location.assign('/bgate/filter/' + document.szukaj.race.value + '/' + document.szukaj.klass.value + '/' + document.szukaj.gender.value + '/' + document.szukaj.level.value + '/'); return false;" name="szukaj">
<div class="content">
          <table>
           <tr class="rowA">
              <td class="first"><b>{% trans "Race" %}</b></td>
              <td><b>{% trans "Class" %}</b></td>
              <td><b>{% trans "Gender" %}</b></td>
              <td><b>{% trans "Levels" %}</b></td>
		<td> </td>
            </tr>
            <tr class="rowB">
              <td class="first"><select name="race">
              <option value="0">{% trans "All" %}</option>
               {% for i in races %}
               <option{% ifequal i race %} selected="selected"{% endifequal %}>{{ i }}</option>
               {% endfor %}
              </select></td>
              <td><select name="klass">
              <option value="0">{% trans "All" %}</option>
               {% for i in classes %}
                <option{% ifequal i klass %} selected="selected"{% endifequal %}>{{ i }}</option>
               {% endfor %}
              </select></td>
              <td><select name="gender">
              <option value="0">{% trans "All" %}</option>
               {% for i in genders %}
                <option{% ifequal i gender %} selected="selected"{% endifequal %}>{{ i }}</option>
               {% endfor %}
              </select></td>
              <td><select name="level">
              <option value="0">{% trans "All" %}</option>
              <option value="1"{% ifequal "1" level %} selected="selected"{% endifequal %}>1-2</option>
              <option value="2"{% ifequal "2" level %} selected="selected"{% endifequal %}>1-8</option>
              <option value="3"{% ifequal "3" level %} selected="selected"{% endifequal %}>8-14</option>
              <option value="4"{% ifequal "4" level %} selected="selected"{% endifequal %}>14-20</option>
              <option value="5"{% ifequal "5" level %} selected="selected"{% endifequal %}>20+</option>
              </select></td>
              <td><input type="submit" name="search" class="button" value="{% trans "Search" %}" /></td>
            </tr>
</table></div></form>
A wspomniane linki stronicowania:
{% if has_previous %}
<h3><a href="/bgate/filter/{{ race }}/{{ klass }}/{{ gender }}/{{ level }}/{{ previous }}/">{% trans "Previous Page" %}</a></h3>
{% endif %}
{% if has_next %}
<h3><a href="/bgate/filter/{{ race }}/{{ klass }}/{{ gender }}/{{ level }}/{{ next }}/">{% trans "Next Page" %}</a></h3>
{% endif %}


Wynik

djfilter1
djfilter2
djfilter3
RkBlog

Django, 14 July 2008,

Comment article