Sensul unei expuneri dinamice a situaţiilor şcolare (şi nu doar în momentul încheierii unui ciclu şcolar) este inspirat din sportul de performanţă: rezultatele curente ale potenţialilor competitori influenţează pozitiv efortul propriu de perfecţionare. Analog, putând observa în orice moment rezultatele proprii, comparativ cu rezultatele celorlalţi şi cu mediile clasei (pe obiecte, arii curriculare, etc.) - ar fi de sperat să constatăm o motivare suplimentară pentru îmbunătăţirea continuă a pregătirii proprii, în rândul elevilor…
Catalogul şcolar şi registrul matricol sunt documente statice şi a le imita ca atare într-o aplicaţie Web ni se pare insuficient/lipsit de sens; în aplicaţia respectivă avem de înregistrat/copiat notele şi absenţele din catalog - dar acestea vor trebui prezentate într-un context dinamic, sintetizând în fiecare moment anumiţi parametri ai stării curente a elevului/clasei.
Prevedem următoarea derulare: dirigintele clasei trimite un fişier conţinând numele, prenumele şi CNP-ul elevilor săi; pe baza acestui fişier se constituie în baza de date înregistrările prevăzute în cadrul aplicaţiei - pentru elevi şi pentru conturile de autentificare. Contul de "diriginte" permite gestionarea periodică a datelor clasei (înregistrarea noilor note şi a numărului de absenţe ale elevilor); prin intermediul contului propriu, elevul (ca şi dirigintele) poate vizualiza "starea curentă" a clasei sale - constând într-o serie de caracteristici statistice la momentul respectiv (pentru fiecare elev din acea clasă, comparativ cu valori medii pe clasă, obiect şi arie curriculară).
Se impune autentificarea nu numai pentru diriginte (ceea ce este firesc, acesta având în mod inerent rol de "administrator" al datelor clasei sale) - dar şi pentru elevi, aceştia nefiind de acord în general, ca situaţiile şcolare proprii să fie publicate… Elevii sunt astfel predispuşi ascunderii lucrurilor; educaţi ca atare - nu putem aştepta decât reacţii disproporţionate (binecunoscute deja), în momentul publicării globale a rezultatelor "dezastruoase" ale examenelor finale de bacalaureat. Ascunderea rezultatelor curente (de care sigur vor depinde rezultatele finale) îi lipseşte pe elevi de şansa confruntării concrete cu alţii de-a lungul timpului - confruntare care (dacă ar fi bine regizată) ar putea emana tensiunea internă necesară pentru progres şi ar obiectiva aprecierea rezultatelor proprii.
Realizăm aplicaţia folosind Python şi framework-ul Django. Autentificarea şi partea de administrare pot fi bazate pe aplicaţii încorporate în Django: django.contrib.auth prevede modele de date pentru utilizatori, grupuri de utilizatori, permisiuni şi oferă infrastructura necesară pentru gestionarea acestora (de la crearea tabelelor necesare în baza de date, până la şabloane de răspuns HTML pentru cereri precum http://.../login/, sau /password_change/); iar interfeţele de administrare pot fi cele oferite prin aplicaţia django.contrib.admin.
În principiu, interfaţa de administrare oferită dirigintelui ar trebui să permită acestuia să posteze fişierul iniţial de date ale elevilor (urmând eventual ca imediat, el să poată începe să înregistreze notele şi absenţele).
Iniţial însă (pe http://colar.docere.ro - desfiinţat între timp) nu am procedat astfel şi… m-am păcălit: anume, cerusem ca fişierul respectiv să-mi fie trimis prin e-mail, urmând ca eu însumi să postez fişierul respectiv (folosind interfaţa de administrare pentru "superuser"); dar astfel, am primit prin e-mail fişiere ".DOC" (formatate pentru imprimantă, folosind Microsoft-Word), de pe care a trebuit să extrag în prealabil informaţiile necesare…
Mai precis, nu să "posteze fişierul" (că atunci, nu putem evita ".DOC"…), ci să pasteze într-un <textarea> informaţiile din acel fişier (după un simplu "COPY" de pe DOC-ul respectiv); desigur, trebuie prevăzută o anumită funcţie care să verifice datele (de exemplu, corectitudinea CNP-ului), înainte de crearea conturilor şi a celorlalte înregistrări aferente elevilor, în baza de date.
În principiu, ar fi de prevăzut posibilitatea ca elevii să-şi gestioneze ei înşişi, conturile (şi putem implica în acest scop, aplicaţia django-registration). Dar iniţial (ulterior, vom combina totuşi cu "django-registration") preferăm cea mai simplă soluţie de creare automată (în masă) a conturilor: bazăm conturile (în speţă, "username" şi "password") pe CNP-urile elevilor; aceasta este cea mai simplă soluţie pentru programator, dar… mai ales pentru utilizatori (de exemplu, n-ar mai apărea problema "am uitat parola"). Iar în cazul existenţei unor "nazuri" (cică: "CNP-ul este secret", sau "confidenţial") - putem pur şi simplu, să… inventăm CNP-uri (vezi Validarea unui CNP).
Trebuind vizată o operare cât mai simplă (şi cât mai familiară), putem angaja din start framework-ul Bootstrap - care oferă un cadru pentru exprimarea şi exploatarea comodă a unei aplicaţii chiar şi prin intermediul telefonului mobil.
Partea aplicaţiei care aproape că revine exclusiv programatorului (nemaiputând să se bazeze pe aplicaţii preexistente), constă în conceperea modelelor de date, adecvate reflectării dinamice (prin intermediul diriginţilor) a situaţiilor şcolare pe parcursul perioadei de şcolarizare.
Aici ne vom referi numai la datele specifice aplicaţiei 'stareCurentă', acestea trebuind gestionate direct de către diriginţi (în timp ce datele pentru 'cursAnual' sunt derivate aproape automat, necesitând din partea diriginţilor cel mult unele corecţii de medii în urma vreunui examen de corigenţă).
Entităţile care trebuie modelate (reflectând conţinutul şi interdependenţele specifice) ar fi acestea: arie curriculară, obiect (fiecare ţinând de o anumită arie), clasă, elev (ţinând de o anumită clasă), note şi absenţe; pentru ultimele două "entităţi" vom evidenţia mai încolo că ar fi mai bine dacă… am renunţa la ele (încorporându-le - însă, în mod dinamic - în "elev").
Creem un proiect Django, "colanrug":
vb@vb:~$ django-admin.py startproject colanrug
În fişierul settings.py declarăm baza de date "larcms" (sub MySQL) pe care o vom folosi în proiect:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', # MySQL 'NAME': 'larcms', 'USER': 'vb', 'PASSWORD': '', # MySQL-password 'HOST': '', # Set to empty string for localhost. 'PORT': '', # Set to empty string for default. } }
Folosind interpretorul mysql creem baza de date "larcms":
mysql> create database larcms default character set utf8 collate utf8_romanian_ci;
Prevederile pentru "character set" se datorează (cam ca de obicei…) păţaniilor ulterioare (legate în acest caz de gestionarea Python-MySQL a datelor care conţin caractere româneşti).
Apoi, creem aplicaţia "stare", fie folosind utilitarul manage.py:
vb@vb:~/colanrug$ python manage.py startapp stare
fie direct (creând din start şi alte fişiere necesare, pe lângă cele "standard"):
vb@vb:~/colanrug$ mkdir -p stare stare/templates vb@vb:~/colanrug/stare$ touch models.py views.py urls.py admin.py __init__.py
Într-o formulare suficientă, sensul acestor fişiere este următorul: în urls.py vor trebui înregistrate corespondenţele dintre cererile HTTP la care să răspundă aplicaţia şi funcţiile de rezolvare a acestora, plasate în views.py; aceste funcţii vor folosi models.py dacă sunt necesare operaţii care implică baza de date, vor prelucra după caz datele respective şi vor transfera datele de răspuns către un şablon de răspuns HTML constituit în /templates; acesta va fi expandat "automat" prin încorporarea datelor primite în locurile rezervate în acest scop, iar apoi pagina astfel completată va fi pasată serverului de la care s-a primit cererea, pentru a o trimite ca răspuns.
Adăugăm 'stare' în INSTALLED_APPS din fişierul settings.py (în mod standard - deja avem înscrise acolo aplicaţiile 'django.contrib.auth', 'django.contrib.admin', şi altele).
În fişierul "models.py" să definim pentru început, Arie:
# -*- coding: utf-8 -*- from django.db import models class Arie(models.Model): nume = models.CharField(max_length=32, help_text=u'Aria curriculară') acro = models.CharField(max_length=2, verbose_name=u'Acronim') def __unicode__(self): return self.nume
Modulele din django.db oferă o interfaţă de programare (folosind Python) pentru lucrul cu baze de date. Un "model" exprimă în Python o înregistrare dintr-un tabel de date (indiferent că este vorba de MySQL, de SQLite, sau alte câteva sisteme de gestiune a bazelor de date); de exemplu, definiţia de mai sus ar corespunde unei înregistrări dintr-un tabel creat în MySQL prin:
CREATE TABLE stare_arie ( id integer NOT NULL AUTO_INCREMENT PRIMARY KEY, nume varchar(32), acro varchar(2), );
Un model poate conţine şi alte specificaţii asupra obiectului Python corespunzător unei înregistrări din tabelul de date (şi nu trebuie să se limiteze la a defini câmpurile acestuia). Opţiuni ca help_text sunt folosite pe interfaţa de administrare (în django.contrib.admin). Metoda __unicode__() adăugată modelului va servi pentru a reprezenta ca şir de caractere obiectul respectiv (în şabloanele HTML).
Tabelele de date corespunzătoare modelelor definite astfel pot fi create şi manual, dar desigur că este preferabil să se folosească utilitarul manage.py syncdb.
Adăugăm şi definiţiile pentru Obiect şi Clasa (aici nu mai redăm şi __unicode__()):
class Obiect(models.Model): nume = models.CharField(max_length=64, unique=True, help_text=u'simplu "Italiană", nu "Limba italiană"') arie = models.ForeignKey(Arie) # un Obiect ţine de o anumită Arie class Clasa(models.Model): cod = models.CharField(max_length=2, unique=True) nume = models.CharField(max_length=4)
Prin interfaţa de administrare oferită, diriginţii vor putea înregistra noi obiecte şi help_text-ul prevăzut mai sus vrea să prevină introducerea de elemente inutile. Dar sensul "dacă există obiectul 'Italiană', atunci nu înfiinţa şi obiectul 'Limba italiană'" este mai puţin accesibil, iar simpla atenţionare este insuficientă; rezolvarea ar consta în redefinirea validatorului pentru unique (încât "italiană" şi "limba italiană" să fie găsite ca fiind identice, caz în care s-ar refuza înregistrarea).
În schimb, clasele (cel puţin, în versiunea iniţială a aplicaţiei) au fost înfiinţate de către "superuser", folosind direct MySQLdb - modul implicat şi în django.db - într-un script independent:
# -*- coding: utf-8 -*- CLASA = ( (u'1A', u'ixA'), (u'1B', u'ixB'), #... clasele a IX-a (prefix 1) (u'2A', u'xA'), (u'2B', u'xB'), #... clasele a X-a (prefix 2) (u'3A', u'xiA'), #ş.a.m.d.; clasele a XI-a (prefix 3) (u'4A', u'xiiA'), # clasele a XII-a (prefix 4) ) import MySQLdb as mdb conexiune = mdb.connect('localhost', 'vb', '', 'larcms'); cursor = conn.cursor() for clasa in CLASA: cursor.execute("INSERT INTO stare_clasa SET cod = '%s', nume = '%s'" % (clasa[0], clasa[1])) conexiune.commit() cursor.close() conexiune.close()
Pentru câmpul "cod" am avut iniţial această justificare: conturile elevilor vor conţine în partea de "username" codul clasei - încât să fie cât mai uşoară identificarea datelor clasei din care face parte elevul. De fapt, ideea nu este tocmai bună, scăpând faptul că în anul următor acelaşi elev va face parte din altă clasă (astfel că "username" va trebui modificat).
Scăpările menţionate sau sugerate (şi vor mai fi, mai jos) se datorează evident tentaţiei de a lansa cât mai repede "în producţie", o aplicaţie care de fapt este încă în stadiul "de dezvoltare" (dar poate că procedând tocmai aşa, poţi profita de "feedback" pe parcursul dezvoltării).
CNP-ul elevului face parte din mecanismul de autentificare prevăzut iniţial, putând fi şi mascat prin intercalarea unor caractere non-cifră arbitrare. Astfel "1520701374061" - un CNP valid, instituit ca parolă iniţială a unui elev - va putea fi schimbat (printr-un "change_password") fie şi în ceva urât de reţinut: "ala1ba5la2_etc__07.@.0137jud4061frumushika".
Pentru verificarea corectitudinii CNP-ului putem folosi metoda to_python(self, value) din Field() (clasa de bază pentru toate tipurile de câmp, definită de /django/db/models/fields/__init__.py); în acest scop introducem un nou tip de câmp, bazat pe CharField (considerând CNP drept şir de cifre şi nu număr) şi redefinim metoda "to_python" moştenită de la CharField:
import re from django.core import exceptions class CnpField(models.CharField): def to_python(self, value): value = super(CnpField, self).to_python(value) # (trece întâi prin validările de bază, pentru CharField) cnp = re.sub(r'\D', "", value) # ignoră non-cifrele # (permiţând "mascarea" CNP-ului real) if len(cnp) != 13: raise exceptions.ValidationError(u'NU are 13 cifre') sab = [2,7,9,1,4,6,3,5,8,2,7,9] # şablonul de validare CNP ctrl = sum(sab[i]*int(cnp[i]) for i in xrange(12)) q = ctrl % 11 # cifra "de control" (ultima din CNP) if q == 10: q = 1 if q != int(cnp[12]): raise exceptions.ValidationError(u'Ultima cifră: %s' % q) return value
Verificarea corectitudinii vizează aici numai cifra de control (nu şi câmpurile aferente datei de naştere, etc.); această relaxare creează posibilitatea introducerii unui CNP fictiv, în situaţia când este de adăugat un elev căruia nu i se cunoaşte CNP-ul: introducând de exemplu '2222222222222', se va atenţiona pe formularul respectiv Ultima cifră: 5 şi după corectură, acest CNP-fictiv va fi admis de validarea definită mai sus.
Desigur, în modelul Elev specificăm acum 'cnp' ca fiind un CnpField() (şi nu CharField):
class Elev(models.Model): nume = models.CharField(max_length = 64) pren = models.CharField(max_length = 64) cnp = CnpField(max_length = 13, unique = True) foto = models.ImageField(upload_to = 'elevi', blank = True) clasa = models.ForeignKey(Clasa) # un Elev ţine de o anumită Clasa def __unicode__(self): return u'%s %s' % (self.nume, self.pren) class Meta: ordering = ('nume', 'pren') permissions = (("can_view", u"Acces situaţii şcolare"),)
În Meta putem seta opţiuni (nelegate direct de instanţa sau înregistrarea curentă) care apoi vor fi utilizate intern în diverse circumstanţe. Astfel, Options.ordering determină ordinea de redare a înregistrărilor selectate din tabel; iar Options.permissions asigură updatarea automată a tabelului de permisiuni ale utilizatorilor (constituit de django.contrib.auth), după crearea unui nou Elev (în cazul de faţă; odată autentificat, elevul poate consulta situaţia curentă a clasei).
N.B.: câmpul 'clasa' nu prea este potrivit în Elev… (în anul următor acelaşi elev va fi în altă clasă)
Pe lângă scăpările deja semnalate, modelarea iniţială pentru notele elevilor se dovedeşte proastă, întrucât implică o "bucăţălire" exagerată a datelor:
class Nota(models.Model): obiect = models.ForeignKey(Obiect) # fiecare Nota ţine de un anumit Obiect elev = models.ForeignKey(Elev) # şi de un anumit Elev note = CommaSeparatedIntegerField(max_length = 64, help_text=u"separaţi prin ',' fără spaţiu: 7,10,8") teza = models.IntegerField(max_length=2, blank=True, null=True) # opţional
Poate să fi respectat mai sus, maniera obişnuită de lucru cu tabele relaţionate (în fond, avem de înscris fiecărui elev şi obiect câte o secvenţă de note); dar rezultă o bucăţălire a datelor care este cam greu de "înghiţit": nu numai dificil de gestionat în mod eficient, dar şi incomod de "administrat".
Cu anumite specificări în fişierul "admin.py", putem obţine uşor o interfaţă de administrare care să grupeze Elev, Nota şi Absent (permiţând introducerea într-un singur pas şi a unor modificări pe Elev-ul respectiv şi a valorilor Nota pentru diversele obiecte, respectiv Absent pentru diversele luni). Dar poţi fi şi înjurat, pentru câte clickuri (pentru adăugare de Nota, selectare de Obiect, etc.) ar avea de făcut dirigintele, ca să introducă situaţia unui elev!
Pe de cealaltă parte, numărul de înregistrări Nota devine ridicol de mare; de exemplu, o funcţie din "views.py" care are de prelucrat notele pentru o clasă cu 30 de elevi, pe un număr de 17 obiecte - va avea de-a face cu o listă Python conţinând 30*17 = 510 obiecte Nota.
De ce să punem dirigintele să aleagă mereu obiectul şi să sară dintr-o casetă în alta pentru a înscrie notele şi teza şi de ce să operăm cu câte 500 de obiecte pentru fiecare clasă de elevi? În loc să căutăm remedieri (customizarea interfeţei de administrare; reducerea numărului de accesări la baza de date; etc.) - cel mai bine este să eliminăm fără nici un regret modelele Nota şi Absent!
Ar fi mult mai comod (pentru diriginte) dacă ar fi de completat un text care deja expune obiectele (împreună cu notele înscrise anterior), grupate eventual pe arii curriculare:
### : Notă,Notă, Notă, Teză ### (virgulă după fiecare Notă, spaţiu înainte de Teză)
[LC] Română: 8,9, 7
[LC] Engleză: 7,6,
[LC] Franceză: 6,
[MŞ] Matematică: 4,4, 6, 5
[MŞ] Fizică: 4, 7,
...
Acest text este un şir de caractere care poate fi sintetizat prin:
{'['acronim_arie']' nume_obiect':' {{notă','}* {teză}}'\n'}+
unde secvenţa dintre acolade este opţională, dar dacă acolada finală este urmată de "*" atunci acea secvenţă poate fi repetată de zero sau mai multe ori - iar dacă este urmată de "+", atunci ea trebuie să apară o dată sau de mai multe ori; un caracter cuprins între apostrofuri obligă prezenţa acelui caracter în locul respectiv din şir (caracterul '\n' desemnează "sfârşitul de rând"). De exemplu, o secvenţă vidă, secvenţa 9, şi secvenţa 7,10,9, - sunt toate, acoperite de şablonul {notă','}*.
Asociem fiecărui elev un astfel de text pentru note şi unul analog pentru absenţe:
LUNI = [u'Septembrie',u'Octombrie',u'Noiembrie',u'Decembrie', u'Ianuarie',u'Februarie',u'Martie',u'Aprilie',u'Mai',u'Iunie',] class Notesem(models.Model): elev = models.ForeignKey(Elev) note = models.TextField( default = lambda: ': \n'.join([''.join(['[',ob.arie.acro,'] ',ob.nume]) for ob in Obiect.objects.all()]), help_text=u'Română: 7,10,8, 6 (note şi teză)' ) absents = models.TextField( default = lambda: ': \n'.join(LUNI)+': ', help_text=u'Decembrie: 10 6 (10 motivate 6 nemotivate)', verbose_name="Absenţe" )
Pe câmpurile 'note' şi 'absents' am setat opţiunea default prin câte o funcţie anonimă (creată prin operatorul lambda); funcţiile respective vor fi executate în momentul creării unui nou obiect 'Notesem', iar rezultatele "returnate" vor fi înscrise acestor câmpuri.
În funcţia setată pe default pentru câmpul 'note' am obţinut secvenţa dintre acolade sintetizată mai sus, dar fără porţiunea de după caracterele ':' (fără note şi teze): ': \n'.join([Listă]) concatenează elementele listei, dar adăugând fiecăruia la sfârşit ': \n'; Listă este formată prin concatenarea listei constituită de '[', ob.arie.acro, '] ', ob.nume, cu ob parcurgând toate obiectele - acestea fiind obţinute prin Obiect.objects.all() (pentru fiecare ob rezultă un element în Listă).
Am procedat analog, pe default-ul pentru câmpul 'absents'.
Pentru a rezolva o cerere ca "vreau să văd situaţia curentă a elevului", funcţia din views.py căreia i s-a pasat cererea va trebui să obţină obiectul Notesem corespunzător elevului şi eventual… să-l paseze ca atare către şablonul HTML prevăzut pentru finalizarea răspunsului.
Dar sunt de calculat şi de înglobat în răspuns, nu numai notele din câmpul 'note', ci de exemplu şi mediile curente pe fiecare obiect; cine trebuie să facă aceste calcule - view-ul, sau poate o funcţie javaScript constituită în şablonul HTML aferent, sau însuşi modelul Notesem?
Aceleaşi calcule pot fi necesare şi altor view-uri - prin urmare asemenea prelucrări trebuie "scoase" în exteriorul acestor funcţii (deşi, nu neapărat în exteriorul lui views.py); dintr-un acelaşi motiv este de evitat şi soluţia javaScript.
Cel mai "înţelept" este să adăugăm modelului Notesem metode care să constituie din textul propriului câmp 'note', valorile necesare ulterior view-urilor.
Mai întâi, pre-compilăm expresiile regulate prin care vom extrage de pe o linie din textul 'note' numele obiectului, secvenţa de note şi teza - înscriind la începutul lui "models.py":
import re NUME = re.compile(r"(.+)\:") # [LC] Română: NOTE = re.compile(r"\s*(\d+),") # 6,8,7, (sau 6,8, 7,) TEZE = re.compile(r"\s+(\d+)$") # 6,8,7, 6 (teza este ultimul număr)
şi adăugăm imediat un "helper" care, primind o listă de note (eventual vidă) şi teza, returnează media şcolară corespunzătoare (cu "două zecimale exacte", conform definirii oficiale):
def _get_medie(note, teza): media = 0.0 if note: m = sum([int(n) for n in note]) / float(len(note)) media = int(m*100) / 100.0 if teza: m = (3*q + teza) / 4.0 media = int(m*100) / 100.0 return media
Completăm definiţia modelului Notesem adăugându-i o metodă prin care textul din câmpul 'note' este "convertit" într-un dicţionar Python {nume_obiect: [[listă_note], teză, medie_obiect]}:
def note_dict(self): rows = self.note.splitlines() filtru = [row for row in rows if row.strip()[-1] not in ':'] # numai dacă urmează Note obiecte = [re.match(NUME, row).group(1) for row in filtru] note = [re.findall(NOTE, row) for row in filtru] teze = [re.findall(TEZE, row) for row in filtru] medii = [_get_medie(no[0],int(*no[1])) for no in zip(note, teze)] return dict(zip(obiecte, zip(note, teze, medii)))
În rows se obţine o listă Python, constituită din rândurile individuale existente în textul înregistrat în câmpul 'note'; această listă este filtrată, reţinând numai "rândurile" corespunzătoare obiectelor la care s-au înregistrat note. Apoi (folosind metode de lucru cu expresii regulate oferite de modulul re) se constituie o listă a obiectelor, una a notelor şi o listă a tezelor.
Fiecare element din lista obiecte este un şir de caractere (numele obiectului), dar în celelalte două liste fiecare element este el însuşi o listă (fiindcă re.findall() returnează o listă Python). De aceea, teza este transmisă la apelul _get_medie() prin int(*no[1]) - "dereferenţiind" lista no[1] (care conţine cel mult un singur element, reprezentând nota la teză).
zip([iterable, ...]) returnează o listă de tupluri (redate folosind parantezele rotunde), constituite din elementele de aceleaşi ranguri ale argumentelor; astfel, zip(note, teze) va da spre exemplu, lista de tupluri [(['7', '8', '9'], ['7']), (['8', '9', '9'], []), ].
Pentru a furniza şi un dicţionar al absenţelor, avem o metodă similară celeia redate mai sus.
Acum, un view care primeşte o cerere pentru "situaţia curentă a elevului" va trebui doar să obţină obiectul 'Notesem' corespunzător şi să invoce metodele acestuia, pentru constituirea variabilelor de transmis şablonului HTML; de exemplu:
def stare_elev(request, elev_id): elev = get_object_or_404(Elev, id=elev_id) notesem = Notesem.objects.get(elev=elev) return render_to_response( 'stare/templates/show_elev.html', {'elev': elev, 'note_dict': notesem.note_dict(), #'absente': notesem.abs_dict(), }, context_instance = RequestContext(request) )
Şablonul HTML aferent "show_elev.html" poate fi rezumat la:
<ul> {% for obiect, note in note_dict %} <li>{{obiect}}: <span style="color:blue">{{note.0|join:", "}}</span> <span style="color:red">{{note.1|join:""}}</span> <b>{{note.2|default:""}}</b> </li> {% endfor %} </ul>
Django va expanda acest fişier, înlocuind variabila {{obiect}} cu cheia 'obiect' curentă din tagul iterator {% for ... %}, conectând prin ", " notele din variabila note.0, etc. - rezultând o listă cu elementele redate sub forma: Română: 8,8,9 9 8.49 (aici media "matematică" este 8.50, dar "cu două zecimale exacte" este 8.49; de aceea am considerat util să nu rotunjim valorile medii, în 'Notesem').
vezi Cărţile mele (de programare)