momente şi schiţe de informatică şi matematică
To attain knowledge, write. To attain wisdom, rewrite.

Un proiect PGN-games cu R, Django şi PostgreSQL

AWK | Django | PGN | PostgreSQL | Python | limbajul R | sed | virtual host | şah
2017 oct

Reluăm "slightchess", chiar de la capăt; anterior (v. all_titles, 2014 / iulie - septembrie) analizam fişierul PGN (cu modulul Python pyparsing) şi formulam câteva programe Python pentru a extrage partidele respective în tabele MySQL, după care am constituit aplicaţia Web menţionată folosind Django 1.8 (şi pachetul javaScript formulat în Modelarea tablei şi jocului de şah).

Acum vom folosi R (obţinând din fişierul PGN dat un obiect 'data.frame', uşor de exploatat), PostgreSQL (către care migrează cei care foloseau MySQL în proiectele lor, de când MySQL a fost achiziţionat de către ORACLE, din motive şi în scopuri comerciale) şi Django 1.11 (iar dintr-un anumit loc, Django 2.0).

1. Eliminări prealabile (cu sed şi awk)

De la instantchess am descărcat o colecţie de partide "GA_10-17-2017.pgn" (jucate de mine într-o anumită perioadă, în regimul de 15 minute). Dacă partidele respective m-ar interesa doar ca şahist, aş folosi liniştit de exemplu xboard, pentru a le revedea şi eventual, pentru a analiza mutările; dar aici lucrăm cam ca un programator care (neavând ceva mai rentabil de făcut) vrea să înţeleagă parcursul stufos al unei aplicaţii Web nebanale şi caută să se familiarizeze cu noi instrumente.

Fişierul PGN respectiv măsoară aproape 210 KB şi arată cam aşa:

[Event "InstantChess"]
[White "андрей чер"]
[Black "vlad.bazon"]
[Result "1-0"]
[WhiteIFlag "RU"]
[BlackIFlag "RO"]
    # un rând separator faţă de lista mutărilor
1. e4 c5
2. Nf3 d6
  ...
58. Bc4+ Kb6
59. Kxd4 1-0

    # două rânduri libere, separând faţă de partida următoare
[Event "InstantChess"]
[White "zxc039"]
[Black "vlad.bazon"]
[Result "0-1"]
[WhiteIFlag "IL"]
[BlackIFlag "RO"]

1. d4 Nf6
2. c4 c5
  ...

Inspectând la nivel hexazecimal, vedem că EOL este cel specific DOS/Windows şi protocoalelor de comunicare prin Internet ("sfârşitul de linie" este reprezentat prin doi octeţi, 0x0D şi 0x0A); lucrând pe un sistem Linux, putem elimina caracterele '\r' (codul 0x0D):

vb@Home:~/slightchess/Doc$  sed -i -e 's/\r$//'  GA_10-17-2017.pgn 

De ce a fost necesar să prevedem şi '$'? Dacă fişierul ar fi conţinut numai caractere ASCII, atunci era suficient şablonul 's/\r//'; dar fişierul nostru este de tip "UTF-8 Unicode text", iar secvenţa de octeţi aferentă unui caracter în Unicode ar putea include şi un octet 0x0D - caz în care acest octet ar fi şters şi nu neapărat, cel de la sfârşitul liniei (indicat prin construcţia '/\r$/').

În general, eliminarea din fişier a unor caractere inutile (în contextul de lucru respectiv) este importantă nu atât fiindcă se reduce dimensiunea fişierului (de exemplu, eliminând mai sus '\r' fişierul s-a scurtat cu 20 KB), cât şi pentru faptul că vom putea formula expresii de căutare mai simple. De exemplu, prima linie din fişier ar fi citită drept "şir de caractere", fiind înregistrată în memorie prin secvenţa de caractere "[Event \"InstantChess\"]" (în care - formal vorbind - ghilimelele interioare au fost prefixate cu escape, pentru a asigura corectitudinea încadrării cu ghilimele a şirului); o expresie regulată prin care să obţinem valoarea InstantChess din "Event" va trebui de regulă, să folosească metacaractere suplimentare (dublând mereu '\').

Prin urmare - pentru a simplifica operaţiile viitoare de extragere a unor date din fişier - să eliminăm şi ghilimelele:

vb@Home:~/slightchess/Doc$ sed -i -e 's/"//g'  GA_10-17-2017.pgn

Precizăm că dacă nu adăugam specificatorul "g" (de la global) în finalul expresiei de substituire, atunci s-ar fi şters numai primul caracter " de pe fiecare linie în care apare (mai precizăm că în general, o expresie de substituire are forma 's/şablon_expresie_de_înlocuit/expresie_de_înlocuire/' iar caracterele speciale implicate trebuie "escapate").

Mai avem o simplificare de făcut, având în vedere că nu ne interesează mutările individuale (decât în momentul foarte îndepărtat, când va fi să le redăm în vreo fereastră), ci lista implicită a acestora; următoarea comandă produce fişierul "GA_10_17.pgn", în care mutările dintr-o aceeaşi partidă apar pe o singură linie de text (şi nu separate pe linii, ca în fişierul iniţial):

awk '/^[0-9]+/{printf "%s ", $0; next} 1'  GA_10-17-2017.pgn  >  GA_10_17.pgn

Dacă linia curentă din fişier începe cu o secvenţă de cifre (numărul mutării, desigur), se execută comenzile indicate între acolade - anume, se scrie linia respectivă (referită prin $0) în formatul "%s " (ca "şir de caractere", adăugând un caracter spaţiu în final - deci înlocuind '\n' cu spaţiu) şi apoi se avansează la următoarea linie; altfel (dacă nu începe cu secvenţă de cifre), acea linie se scrie normal (prin comanda desemnată simplu 1 - specific pentru awk - de după acoladă).

Desigur, fişierul rezultat are şi linii foarte lungi:

vb@Home:~/slightchess/Doc$ file  GA_10_17.pgn 
GA_10_17.pgn: UTF-8 Unicode text, with very long lines

dar nu ne interesează să-l vedem într-un editor de text, ci să-l prelucrăm prin programe.

Este interesant de remarcat că înainte de a începe să faci propriu-zis ceva (sau să re-faci), este bine să te gândeşti întâi ce poţi să elimini!
Dar eliminarea trebuie efectuată cu grijă: uneori, se vor elimina şi elemente pe care nu le-ai avut în vedere în mod explicit pentru aceasta; de exemplu, prin ultima comandă awk() formulată mai sus este "ştearsă" şi linia liberă de după ultima mutare din partidă - ceea ce înseamnă că acum partidele sunt separate una de alta nu prin două linii libere ca în fişierul PGN iniţial, ci printr-una singură (lucru pe care nu l-am intenţionat în mod explicit şi care de data aceasta, nu-i rău).

2. 'data.frame' (în R) pentru fişierul PGN; statistici

Fişierul "GA_10_17.pgn" arată cam aşa:

 ...
[Event InstantChess]
[White Sveinbjörn Jónsson]
[Black vlad.bazon]
[Result 0-1]
[WhiteIFlag IS]
[BlackIFlag RO]

1. e4 c5 2. Nf3 d6 3. d4 cxd4 ... 36. Rxg6 Rd1+ 0-1

[Event InstantChess]
[White vlad.bazon]
[Black ديب بلو]
[Result 1/2-1/2]
[WhiteIFlag RO]
[BlackIFlag EG]

1. d4 d5 2. c4 dxc4 3. e4 b5 ... 77. Nxg4 1/2-1/2 
  ...

Nu mai avem ghilimele, iar lista mutărilor este o singură linie de text (iar partidele sunt separate una de alta prin câte o singură linie vidă). După mutările partidei figurează şi rezultatul acesteia (deja precizat în tag-ul PGN Result); dar nu mai eliminăm nimic din fişier (fiind de dorit să păstrăm totuşi, formatul PGN al acestuia).

Următoarea funcţie R citeşte fişierul PGN indicat, asumă o listă de tag-uri PGN interesante şi instituie un obiect "data.frame" (în vorbirea obişnuită l-am numi "tabel") în care primele coloane corespund tag-urilor, iar ultima va înregistra listele de mutări:

# Pentru fişier PGN în care:
#    - lista mutărilor este o singură linie de text 
#    - în tagurile PGN s-a eliminat caracterul " (ghilimele)
# Sintetizează PGN într-o structură de date "data.frame"
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
pgn_dataframe <- function(file_pgn, tag_supl=c('WhiteIFlag', 'BlackIFlag')) {  
  con <- file(file_pgn, "r")
  Lines <- readLines(con, encoding="UTF-8")
  close(con)
  
  TAGS <- c('White', 'Black', 'Result')
  if(!is.null(tag_supl)) 
    TAGS <- c(TAGS, tag_supl)
  pgn_df <- as.data.frame(matrix(ncol=length(TAGS)+1, nrow=0))
  colnames(pgn_df) <- c(TAGS, "MoveList")
  
  game <- list()
  for(line in Lines) {
    if(! grepl("^\\s*$", line, perl=TRUE)) {
      if(grepl("^\\[", line, perl=TRUE)) {
        field <- sub("^\\[([a-zA-Z]+).*\\]$", "\\1", line)
        if(field %in% TAGS) 
          game[[field]] <- sub("\\[[a-zA-Z]+\\s+(.+)\\]", "\\1", line)
      } else {
        game[["MoveList"]] <- sub("( 0-1| 1-0| 1/2-1/2)\\s*$", "", line);
        pgn_df <- rbind(pgn_df, game, stringsAsFactors=FALSE)
        game <- list()
      }
    }
  }
  return(pgn_df)
}

În linia 6 am prevăzut tag-urile PGN principale (cine cu cine a jucat şi rezultatul); la acestea sunt adăugate apoi şi eventualele tag-uri suplimentare primite din linia de apel a funcţiei. În linia 9 se constituie obiectul "pgn_df" de tip "data.frame", deocamdată vid (cu zero linii); fiecare coloană (exceptând-o pe ultima) corespunde - şi prin linia 10 este etichetată la fel - cu câte unul dintre tag-uri, iar ultima coloană serveşte pentru înregistrarea listelor de mutări.

Prin "structura repetitivă" din liniile 13-25 se parcurg liniile de text citite iniţial din fişier (v. linia 3), angajând o listă auxiliară "game" care iniţial este vidă. Dacă linia curentă nu conţine caractere diferite de spaţiu (ceea ce se testează în linia 14), atunci se trece imediat la următoarea linie; dacă primul caracter din linie este "[" (testul din linia 15), atunci linia respectivă reprezintă un tag PGN şi în linia 16 se extrage numele acestuia; dacă acest nume este unul dintre cele prevăzute, atunci în linia 18 se extrage valoarea tag-ului respectiv, stocând-o în lista "game" - după care, se trece la următoarea linie.

Dacă linia curentă nu este vidă şi nu începe cu "[" (v. linia 19), atunci aceasta conţine lista mutărilor; în linia 20, ea este extrasă din fişier (ignorând acum, rezultatul anexat în PGN listei mutărilor) şi este adăugată în lista "game". Acum (în urma parcurgerii succesive a liniilor din fişier) "game" conţine şi valorile tagurilor prevăzute şi lista mutărilor, aferente partidei curente; în linia 21 se extinde "pgn-df", adăugându-i lista "game", după care "game" este reiniţializată ca listă vidă, pregătind reluarea ciclului pentru următoarea partidă din fişier.

Redăm câteva teste tipice (ajustând puţin, rezultatele afişate în consola R):

> games <- pgn_dataframe("GA_10_17.pgn")
> str(games)  # inspectează structura de date rezultată
'data.frame':	350 obs. of  6 variables:
 $ White     : chr  "Andrei1" "андрей чер" "zxc039" "Af1001" ...
 $ Black     : chr  "vlad.bazon" "vlad.bazon" "vlad.bazon" "vlad.bazon" ...
 $ Result    : chr  "0-1" "1-0" "0-1" "0-1" ...
 $ WhiteIFlag: chr  "MD" "RU" "IL" "IL" ...
 $ BlackIFlag: chr  "RO" "RO" "RO" "RO" ...
 $ MoveList  : chr  "1. d4 Nf6 2. c4 c5 3. Nf3 e6 4. Bg5 Be7"| __truncated__ ...
> games[13:14, 1:5]  # partidele 13 şi 14, primele 5 coloane
            White      Black  Result WhiteIFlag BlackIFlag
13 Sasna tsrer 26 vlad.bazon     0-1         KI         RO
14     vlad.bazon    ديب بلو 1/2-1/2         RO         EG
> subset(games, Black=="Рафаел Айрапетян")
        White            Black Result WhiteIFlag BlackIFlag
18 vlad.bazon Рафаел Айрапетян    1-0         RO         RU
MoveList
1. d4 d5 2. c4 c6 3. Nc3 e6 4. e4 Bb4 5. cxd5 exd5 6. e5 h6 7. Bd3 Ne7 8. a3 Bxc3+ 9. bxc3 
Bf5 10. Bxf5 Nxf5 11. Qg4 Qd7 12. Ne2 Qe6 13. Rb1 b6 14. O-O O-O 15. Nf4 Qd7 16. Nh5 Kh8 
17. Qh3 Ne7 18. g4 Ng6 19. Bxh6  # rezultatul a fost eliminat

Merită observat că redarea de mai sus a înregistrării games[14, ] este defectuoasă: numele pentru "Black" (care este unul arab, cu scriere direcţionată invers celei obişnuite) figurează după rezultatul "1/2-1/2"; precizăm că în consola R înregistrarea respectivă apare corect - defectul a rezultat numai din operaţia "Copy-Paste" efectuată asupra acesteia (a vedea eventual, bidirecţionalitate).

Desigur, în R putem formula uşor cam orice fel de statistici am dori, asupra datelor respective. Este drept însă, că în cazul de faţă datele nu sunt prea relevante din punct de vedere statistic, fiind vorba de o colecţie de partide proprii, care este şi mică; iar despre parteneri nu avem din fişierul iniţial decât naţionalitatea (nu şi coeficientul valoric determinat de instantchess.com la momentul disputării partidei, nici data calendaristică a acestui moment).

Pentru a obţine "tabelul de contingenţă" între rezultatele partidelor şi culoarea cu care am jucat, adăugăm întâi coloana "myColor" pe care înregistrăm "TRUE" pentru partidele în care am jucat cu piesele albe şi "FALSE" pentru cele în care am jucat cu negrul; apoi folosim comanda xtabs():

> games$myColor <- ifelse(games$White == 'vlad.bazon', TRUE, FALSE)
> xtabs(~ myColor + Result, data = games)
       Result    # contingenţa rezultatelor faţă de culoarea cu care am jucat
myColor 0-1 1-0 1/2-1/2
  FALSE 143  55       3    # cu albul: +143-55=3 (143 câştigate, 55 pierdute, 3 remize)
  TRUE   37 109       3    # cu negrul: +109-37=3

Procentul de partide pierdute este ceva mai mare pentru partidele în care am jucat cu albul, faţă de cele în care am jucat cu negrul - iar această constatare statistică dezvăluie că am mai mult succes cu negrul decât cu albul (probabil că am putea explica situaţia, dacă am investiga statistic deschiderile jucate - primele 10-15 mutări). Nu ne ocupăm aici de aspecte statistice, dar încercăm să sugerăm că statistica nu se face de dragul de a face tabele…

Putem proceda la fel ca mai sus şi pentru naţionalităţi - înscriem în coloana "myColor" codul de ţară al negrului (respectiv, al albului) pe liniile corespunzătoare partidelor în care eu am avut albul (respectiv, negrul) şi apelăm iarăşi la xtabs(); folosind with(), putem evita prefixarea numelor de coloane cu "games$":

> games$myColor <- with(games, ifelse(White=='vlad.bazon', BlackIFlag, WhiteIFlag))
> xtabs(~ myColor, data=games)
myColor
AF AL AM AR AT AU AZ BA BE BG BH BR BY CA CH CL DE DO DZ EG ES FI FR GE GR HR 
 1  5  4  2  2  2  6  2  4  3  1  2  8  5  2  4  6  1  2 11  5  1  5  3  4  7 
HU ID IL IN IR IS IT KI KZ LB LT LV MA MD ME MK MN NL NO PE PH PK RO RS RU SE 
 1  9  9  8  3  3  7  1  5  1  3  1  1  1  2  1  1  6  2  1 20  3  5 16 46  5 
SI SN TH TN TR TZ U1 U2 U3 UA UG UK US VE YE ZA ZM 
 2  1  1  1  4  1  1  1  3 14  4 11 40  2  1  3  1 

Vedem imediat (nu-i nevoie de vreo ordonare a rezultatelor) că partenerii cei mai mulţi sunt din Rusia (RU=46) şi din SUA (US=40). Am putea desigur să ne gândim la o statistică privitoare la rezultatele partidelor pe care le-am disputat cu jucătorii din Rusia sau SUA, etc. În orice caz - tocmai nenumăratele posibilităţi şi comodităţi de analiză statistică oferite, atrag şi ideea de a integra datele existente în fişiere text, în structuri specifice limbajului R.

(2*) Dilemele dezvoltării (şi ale dezvoltării unui programator)

Intenţionam acum ceva de genul "3. Bază de date, cu PostgreSQL"; dar mi-am dat seama (la timp, să zicem) că nu aceasta ar fi continuarea adecvată a lucrului, dacă avem de făcut o aplicaţie Web cu Django - fiindcă în principiu, baza de date (fie pentru MySQL, fie pentru PostgreSQL, SQLite, etc.) va fi gestionată din Django, prin interfaţa unitară prevăzută în acest scop; de exemplu, formularea Django "games = Game.objects.filter(partener = par_id)" ar asigura selectarea din tabelul asociat intern obiectului Game a înregistrărilor în care câmpul partener are valoarea indicată la apel, indiferent de caz (MySQL, PostgreSQL, etc.).

Pe de altă parte, ar fi de văzut şi dacă este necesară, o "bază de date"; nu am putea folosi direct obiectul data.frame obţinut deja mai sus? S-ar putea - dat fiind că există deja instrumente de dezvoltare a aplicaţiilor Web folosind R (v. WebTechnologies); aici ne vom baza totuşi pe Django.

Iar privind mai general dezvoltarea, dilemele fi mai adânci; ce folosim? Windows, sau Linux? Folosim cumva, ceea ce ne prevăd de 20 de ani încoace, programele şcolare? (produsele funcţionăreşti de la Microsoft, pseudocod C++ şi interfaţă CodeBlocks, Microsoft VisualFoxPro)
Părerea bine întreţinută instituţional este că Windows este mai folositor decât Linux: Windows-ul îţi permite să obţii un "CERTIFICAT DE CONDUCERE A CALCULATORULUI", pe când Linux - nu. Dar aceasta este o simplificare oribilă a realităţii şi o falsificare grosolană a parcursului adevărat al dezvoltării informaticii.

Am evidenţiat şi noi pe aici (iar alţii mai avizaţi, prin alte locuri) că Windows nu oferă nimic unui programator (decât obişnuinţe proaste, dependenţe inutile şi restricţiuni); sed şi awk folosite în mod natural mai sus, sunt instrumente străine Windows-ului, ca şi "linia de comandă", fişierul-text sau orice editor de text şi cod-sursă decent - neîncadrându-se în specificul "point-and-click" şi depăşind cadrul specific unui sistem comercial închis.

Ca şi până acum, ignorăm mai departe valenţele Windows-ului; dar precizăm că şi pe Windows pot fi instalate (cum se ştie, desigur) produsele la care ajungem să ne referim mai jos.

3. Versiuni de Python şi versiuni de Django

În distribuţia de Linux Ubuntu 16.04 ("xenial") avem două versiuni de Python: Python 2.7 - care de pe linia de comandă se lansează cu "python" şi care este folosită intern de Ubuntu - şi Python 3.5, care se poate folosi interactiv prin "python3". Acest fapt devine important dacă avem de folosit pachete bazate pe Python 3.X; de exemplu, Django va abandona acuşi Python 2.7, adoptând definitiv Python 3.X cu X ≥ 5.

Deasemenea, în mod normal ar trebui să avem două versiuni de pip (instrumentul de bază pentru instalarea pachetelor bazate pe Python, de obicei cele înregistrate la PyPI); la un moment dat am instalat "python-pip" de la Ubuntu Software Center (dar nu şi "python3-pip"):

vb@Home:~$ pip2 -V
pip 8.1.2 from /usr/local/lib/python2.7/dist-packages (python 2.7)

Aceasta (pip 8.1) este versiunea oficială adoptată de Ubuntu pentru pachete Python 2.7 şi fiindcă Ubuntu este bazat pe Python 2.7 - cred că este preferabil să păstrez versiunea oficială (de fapt, "versiunea oficială" - 8.1.1 - exista deja la /usr/lib/python2.7/dist-packages/pip; instalând "python-pip" oferit de Ubuntu Software Center, avem un "upgrade" local, păstrând caracterul de "aprobat oficial").

Acum 3-4 ani, am instalat Django 1.8 global (implicând aceleaşi resurse ca şi sistemul - de exemplu, Python 2.7 folosit intern de Ubuntu); nu-mi convine acum, să instalez "global" Django 1.11: ar trebui apoi, să mă ocup şi de actualizarea corespunzătoare a aplicaţiilor pe care le realizasem folosind vechiul Django, iar pe de altă parte noul pachet (instalat global, în locaţia standard /usr/local/lib/python2.7/dist-packages/django) ar implica tot Python 2.7.

Ori acum, aş vrea să realizez o nouă aplicaţie (nu să mă ocup de actualizarea celor existente), folosind Django 1.11 cu Python 3.5 (şi fără a şterge vechiul pachet Django, pentru a nu afecta funcţionarea aplicaţiilor produse anterior cu acesta). Pentru asemenea situaţii, Python 3.6 a introdus conceptul de "mediu virtual", prin modulul venv: se creează directoare de instalare proprii pachetului, conţinând şi resursele necesare funcţionării acestuia.

Putem folosi venv cu Python 3.5 dacă în prealabil, instalăm python3-venv:

vb@Home:~$ sudo apt-get install python3-venv

Se cuvine să precizăm la un moment sau altul, că operăm pe calculatorul propriu; avem deja un user obişnuit 'vb' (cu drepturi depline de lucru în /home/vb/), înregistrat însă în grupul 'sudo' (încât poate executa şi comenzi privilegiate - rezervate altfel lui 'root'). Pentru lucrul "la distanţă" ar fi necesare (vizând acuşi, Apache şi Postgres) anumite ajustări ale drepturilor de citire, scriere şi execuţie lăsate sau nu, pentru anumite fişiere sau directoare, clienţilor.

Creem acum un mediu virtual în care să dezvoltăm aplicaţia noastră:

vb@Home:~$ python3 -m venv envPy3

Ca efect al opţiunii "-m", s-a executat funcţia "main()" din modulul venv, prin care s-a creat o anumită structură de directoare; envPy3/bin conţine scripturi de "activare" (a lucrului în mediul virtual respectiv), scripturi de lansare a programului pip şi un "symlink" la python3 care este denumit "python" (încât după activare, "python" va lansa de fapt "python3" şi nu Python 2.7).

Subdirectorul envPy3/lib/python3.5/site-packages/ conţine modulul pip care va fi folosit pentru instalarea de pachete Python în cadrul mediului virtual creat; de fapt, este o copie a pachetului existent pip-8.1.1 şi când l-am folosi pentru a instala vreun pachet, vom fi atenţionaţi că "You should consider upgrading" pip. Prin urmare, să activăm mediul virtual creat şi înainte de a instala Django, să actualizăm (pentru acest mediu) pachetul pip:

vb@Home:~$ cd envPy3/
vb@Home:~/envPy3$ source bin/activate
(envPy3) vb@Home:~/envPy3$ pip install --upgrade pip
#  Successfully uninstalled pip-8.1.1 \ Successfully installed pip-9.0.1

Să instalăm în sfârşit, Django (apoi, verificăm versiunea):

(envPy3) vb@Home:~/envPy3$ pip install django
(envPy3) vb@Home:~/envPy3$ python -c 'from django import get_version;
> print(get_version())'
1.11.6  # Django 1.11  (versiunea instalată în mediul virtual (envPy3))
(envPy3) vb@Home:~/envPy3$ deactivate  # se reconstituie căile globale
vb@Home:~/envPy3$ python -c 'from django import get_version;
> print(get_version())'
1.8.2  # Django 1.8  (versiunea instalată global)

Precizăm că "source bin/activate" reconfigurează variabila de mediu globală "PATH" astfel încât aceasta să indice pe primul loc calea către subdirectorul bin/ al mediului virtual respectiv (încât, vor fi utilizate executabilele găsite aici, în loc de cele de acelaşi nume din subdirectoarele "bin/" globale; de exemplu, python va lansa Python 3.5 indicat în envPy3/bin şi nu Python 2.7 al cărui executabil este în /usr/bin/); în plus, adaugă în sesiunea de lucru din linia de comandă funcţia deactivate() (prin care se reconstituie vechiul "PATH").

(3*) Apache2 - cu două versiuni de Python? (dilemele dezvoltării...)

Bineînţeles că pentru lucrul într-un mediu virtual cu Django (început în §3), m-am documentat în diverse locuri; peste tot pe unde m-am uitat - lucrurile sunt în regulă: n-avem acum decât să înfiinţăm un proiect Django (folosind django-admin) şi să lansăm "serverul de dezvoltare" - vom obţine în browser fereastra standard "It worked! Congratulations on your first Django-powered page!" etc. (recomandăm călduros, A Complete Beginner's Guide to Django).

Funcţionează şi aplicaţiile realizate anterior cu Django 1.8 şi iată, "funcţionează" şi Django 1.11 din mediul virtual constituit în §3 - deci lucrurile "sunt în regulă"… De fapt nu este aşa, decât în cazul când dezvoltăm aplicaţii pentru http://127.0.0.1:8000 ("localhost"); pentru un "virtual host" nu vom mai obţine "Congratulations", ci mesaje de eroare din partea serverului; putem îndrepta lucrurile, încât să putem apela prin Apache aplicaţii realizate cu Django 1.11 - dar atunci nu vor mai putea fi accesate aplicaţiile vechi, bazate pe Django 1.8 (pentru Python 2.7)…

Pe unde m-am uitat se avea în vedere numai instalarea de Django din mediul virtual (nu şi aplicaţii existente deja, pentru altă versiune de Django - ca în cazul nostru); de aceea, lucrurile erau într-adevăr, în regulă (chiar şi pentru Apache).

Diferenţa esenţială dintre cele două versiuni de Django instalate mai sus, constă în faptul că una (cea instalată "global") funcţionează prin Python 2.7 (propriu sistemului de operare), iar cealaltă (instalată într-un mediu virtual) va trebui să funcţioneze prin Python 3.5. Însă WSGI (interfaţa obişnuită pentru a deservi cu Apache aplicaţii realizate cu Python) nu poate funcţiona decât cu o singură versiune de Python, aleasă şi precizată în prealabil.

Poate că aş reuşi o rezolvare pentru dilema apărută, documentându-mă şi mai mult şi învăţând ceea ce ar fi de învăţat; dar este de aşteptat ca aceasta să-mi ia mult mai mult timp (cu satisfacţii şi rezultate incerte), decât dacă aş face modificările necesare pentru ca vechile aplicaţii să ruleze şi ele, cu Django 1.11 (implicând Python 3.5 pe server). În plus - ce sens are, să păstrez Django 1.8 (numai cu scopul de a asigura funcţionarea vechilor aplicaţii, pentru Python 2.7) ştiind că Django 1.11 este ultima versiune care încă nu a abandonat Python 2.7? Deja de ceva vreme, chiar Ubuntu se străduieşte să treacă definitiv pe Python 3.6.

Vom vedea îndată că putem chiar amâna actualizarea aplicaţiilor vechi, fiind suficientă o singură modificare în configuraţia lui Apache încât să putem accesa alternativ aplicaţii Django bazate pe Python 2.7 (ca şi până acum), respectiv (după modificare) bazate pe Python 3.5. Desigur, aceasta este o soluţie temporară, dar este rezonabilă în acest moment; avem în vedere undeva în viitor, să eliminăm versiunea Django 1.8 (actualizând pentru Django 1.11, aplicaţiile respective).

4. Gazdă Apache şi mod WSGI pentru aplicaţii Django cu Python3

Apache are o structură modulară extensibilă: dacă vrei să permiţi deservirea unor aplicaţii PHP (respectiv Perl, sau Python2, sau Python3, etc.), atunci instalezi (şi activezi) libapache2-mod-php (respectiv -perl, sau -wsgi, sau -wsgi-py3, etc.). Fiecărui modul îi corespunde un fişier ".load" (de exemplu, "perl.load", sau "wsgi.load", etc.) în directorul /etc/apache2/mods-available/, care furnizează serverului locaţia exactă a codului compilat necesar deservirii de aplicaţii bazate pe limbajul respectiv; de exemplu, pentru aplicaţiile noastre cu Python 2.7 avem deja:

vb@Home:~/envPy3/DOC$ cat /etc/apache2/mods-available/wsgi.load
LoadModule wsgi_module /usr/lib/apache2/modules/mod_wsgi.so

N-am verificat dacă se poate ca -wsgi şi -wsgi-py3 să fie instalate împreună (dacă îşi denumesc codul compilat la fel, "mod_wsgi.so" - atunci instalarea uneia înseamnă înlocuirea celeilalte şi deci, nu pot coexista); dar ştim că numai una dintre ele poate fi activă la un moment dat. Dacă le-am avea instalate pe ambele, atunci ar trebui mereu să activăm una şi să o dezactivăm pe cealaltă, pentru a deservi (după restartarea serverului) aplicaţii cu Python2, respectiv cu Python3; dar nu-i cazul să ne gândim mai mult de atât la instalarea împreună a celor două moduri, de vreme ce am decis deja la §(3*) să abandonăm aplicaţiile Django cu Python 2.7.

Evităm deocamdată modificări majore pe serverul Apache şi implicăm în schimb pachetul Python mod_wsgi, instalându-l în mediul virtual creat la §3:

(envPy3) vb@Home:~/envPy3$ pip install mod_wsgi

Lucrul esenţial rezultat astfel este fişierul envPy3/lib/python3.5/site-packages/mod_wsgi/server/mod_wsgi-py35.cpython-35m-x86_64-linux-gnu.so, conţinând codul compilat necesar serverului Apache pentru a deservi aplicaţii cu Python3; înscriem această locaţie în "wsgi.load", comentând linia existentă - încât vom avea (aici, separăm linia pe două rânduri):

(envPy3) vb@Home:~/envPy3$ cat /etc/apache2/mods-available/wsgi.load
# LoadModule wsgi_module /usr/lib/apache2/modules/mod_wsgi.so
LoadModule wsgi_module /home/vb/envPy3/lib/python3.5/site-packages/mod_wsgi/server/
                       mod_wsgi-py35.cpython-35m-x86_64-linux-gnu.so

Dacă acum am restarta Apache, am constata că aplicaţiile cu Python2 nu mai pot fi accesate. Iar dacă am reedita "wsgi.load", decomentând prima linie şi comentând-o pe a doua - vom putea accesa din nou aplicaţiile cu Python2, dar nu şi pe cele cu Python3.

Să constituim un "proiect Django" (dar nu definim deocamdată nicio aplicaţie):

(envPy3) vb@Home:~/envPy3$ django-admin startproject chessg

şi să înscriem în /etc/apache2/sites-available/ fişierul "envPy3.conf" având următorul conţinut, definind o gazdă (un "virtual host") pentru aplicaţiile conţinute în proiectul "chessg" (pentru lizibilitate, am redat pe două rânduri valoarea variabilei python-path):

<VirtualHost *:80>
    ServerName py3.hom
    ServerAlias www.py3.hom
    <Directory /home/vb/envPy3/chessg>
        Options Indexes FollowSymLinks
        AllowOverride None
        Require all granted
    </Directory>
    WSGIDaemonProcess chessg python-path=/home/vb/envPy3:
                                         /home/vb/envPy3/lib/python3.5/site-packages
    WSGIProcessGroup chessg
    WSGIScriptAlias / /home/vb/envPy3/chessg/chessg/wsgi.py
</VirtualHost>

Acest fişier configurează deservirea cererilor http://py3.hom/, indicând mai întâi directoarele care conţin resursele necesare constituirii de către Apache a răspunsului; directiva esenţială este WSGIScriptAlias, care specifică programul "wsgi.py" menit să rezolve cererea menţionată. django-admin a creat deja un şablon pentru acest program şi îl completăm cu o secvenţă care să asigure "activarea" mediului virtual constituit la §3:

# envPy3/chessg/chessg/wsgi.py
"""
https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/
https://modwsgi.readthedocs.io/en/develop/user-guides/virtual-environments.html
"""
import os, sys, site
site.addsitedir('/home/vb/envPy3/lib/python3.5/site-packages')
sys.path.append('/home/vb/envPy3/chessg')
sys.path.append('/home/vb/envPy3/chessg/chessg')
# "Activează" mediul virtual, reordonând căile de căutare a fişierelor executabile (PATH)
prev_sys_path = list(sys.path)
new_sys_path = []
for item in list(sys.path):
    if item not in prev_sys_path:
        new_sys_path.append(item)
        sys.path.remove(item)
sys.path[:0] = new_sys_path

from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chessg.settings")
application = get_wsgi_application()

"Activarea" mediului virtual revine în fond la reordonarea căilor de căutare specificate în variabila globală "PATH", astfel încât să se caute întâi în directoarele existente în mediul virtual respectiv şi numai dacă fişierul executabil căutat nu este găsit aici, să se continue căutarea pe căile uzuale (în /usr/bin/, etc.). Putem vedea uşor despre ce este vorba, afişând PATH înainte şi după activarea mediului virtual:

vb@Home:~/envPy3$ echoM $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
vb@Home:~/envPy3$ which python
/usr/bin/python  # Python 2.7 (înainte de activarea mediului virtual) 
vb@Home:~/envPy3$ source bin/activate
(envPy3) vb@Home:~/envPy3$ echo $PATH
/home/vb/envPy3/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
(envPy3) vb@Home:~/envPy3$ which python
/home/vb/envPy3/bin/python  # Python 3.5 (după activare) 

Înainte de activarea mediului virtual, python este lansat din /usr/bin/, iar după activare - din /home/vb/envPy3/bin/. Nu mi-a reuşit să folosesc comanda Bash source şi în scriptul Python "wsgi.py", încât am adoptat secvenţa de reorganizare a variabilei sys.path preluată din documentaţia modulului Python mod_wsgi.

Până să verificăm deservirea de către Apache a cererilor http://py3.hom/, mai avem de făcut două-trei lucruri simple. Mai întâi, inspectăm fişierul chessg/settings.py indicat drept "DJANGO_SETTINGS_MODULE" de penultima linie din programul "wsgi.py" redat mai sus; deocamdată, în "settings.py" trebuie făcută o singură modificare:

ALLOWED_HOSTS = ['py3.hom', 'www.py3.hom']

Apoi, în fişierul "/etc/hosts" trebuie să adăugăm o linie care asociază site-ului "py3.hom" o anumită adresă IP (anume, cea asociată cu "localhost"):

127.0.0.1  py3.hom  www.py3.hom

Pentru a "activa" în sfârşit site-ul respectiv, mai trebuie să creem un "symlink" pentru "envPy3.conf", în /etc/apache2/sites-enabled/ şi să restartăm serverul:

vb@Home:~/envPy3$ sudo a2ensite envPy3.conf
vb@Home:~/envPy3$ sudo service apache2 reload

Tastând http://www.py3.hom/ în bara de adresă a unui browser, obţinem drept răspuns pagina intitulată "Apache2 Ubuntu Default Page" - ceea ce este corect, fiindcă în "envPy3.conf" nu am declarat un "DocumentRoot" (şi de fapt, în cadrul proiectului pe care l-am înfiinţat nu am creat încă nicio pagină HTML proprie); Apache a folosit atunci definiţia stabilită ca implicită, din /etc/apache2/sites-available/000-default.conf, redând pagina "index.html" existentă în /var/www/html/.

Pe de altă parte, am putut observa când am inspectat fişierul "settings.py", că avem deja nişte "aplicaţii" înregistrate: INSTALLED_APPS = ['django.contrib.admin', ...]" - ceea ce ar putea sugera ideea de a proba de exemplu http://www.py3.hom/admin:

Este de înţeles că pagina afişată face parte din aplicaţia "django.contrib.admin"; avem astfel o confirmare a funcţionării dorite.

Desigur, pe parcurs vom avea de modificat unele lucruri şi de adăugat altele; de exemplu, fereastra "Django administration" redată mai sus deja sugerează că ar trebui să existe o bază de date, în care să avem şi înregistrări pentru "Username" şi "Password".

(4*) O verificare conjuncturală

Întâmplător, a doua zi după ce am operat cele redate în §4, am accesat din Firefox http://py3.hom/admin şi… "a mers" - ceea ce este surprinzător, fiindcă în /etc/apache2/mods-available/wsgi.load decomentasem prima linie şi o comentasem pe a doua (încât - dacă ne luăm după cele arătate mai sus - se pot accesa aplicaţii cu Python2, dar nu şi cele cu Python3).

La prima vedere, acest fapt contrazice susţinerile noastre din §4… Pentru a evita să dau vina pe mecanismele interne ale browserului referitoare la "memoria cache", am accesat http://py3.hom/admin din Chrome (din care nu accesasem anterior, adresa respectivă) - a mers, şi aşa…

Atunci, am încercat să forţez ceva "eroare", ştiind că în fişierul "settings.py" am păstrat DEBUG = True (astfel că în caz de "eroare", este furnizată o pagină informativă); anume, am tastat câte ceva în casetele etichetate "Username" şi "Password" - şi iată un extras al paginii returnate:

Eroarea provine din faptul că nu am constituit încă, vreo bază de date; dar altceva este important de văzut: este indicată versiunea Python 2.7 - deci dacă în secţiunile de cod existente aş fi avut şi secvenţe cu specific Python3 (nerecunoscute de Python2), atunci cu siguranţă că "py3.hom" nu putea funcţiona.

Dar cel mai simplu de pus lucrurile la punct este să comentăm prima linie şi să o decomentăm pe a doua în fişierul "wsgi.load", apoi să restartăm Apache şi să reîncărcăm pagina în browser:

De data aceasta, vedem că versiunea implicată este Python 3.5; vechile aplicaţii, cu Python 2.7 nu mai pot fi accesate (dar acum nu mai obţinem o "pagină informativă", fiindcă pentru aceste aplicaţii avem setarea "DEBUG = False"):

Se dovedeşte astfel că susţinerile noastre din §4 privitoare la posibilitatea coexistenţei aplicaţiilor cu Python2 şi respectiv, Python3 sunt cât se poate de corecte.
Experimentul redat evidenţiază şi faptul că Django 1.11 "merge" încă şi cu Python 2.7; dar "1.11" este şi ultima versiune Django cu această proprietate.

În ceea ce priveşte eroarea "Unable to open database file" evidenţiată mai sus, să observăm că în fişierul de configurare "settings.py" găsim:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    }
}

ceea ce înseamnă că RDBMS - sistem de gestiune a bazelor de date ("relational database management system") - vizat în mod implicit este SQLite (nu a fost necesar să îl instalăm în prealabil, fiindcă Python deja conţine modulul asociat /usr/lib/python3.5/sqlite3).

Colecţiile de partide de şah pe care le vizăm în proiectul nostru ar necesita o bază de date mai degrabă modestă, ca dimensiuni - încât am putea rămâne la SQLite, evitând bătaia de cap necesitată de instalarea şi folosirea unui alt RDBMS; totuşi (având aici, motivaţii didactice), alegem calea mai complicată…

(4**) Învăţămintele instalării şi re-instalării pachetului 'postgresql'

Acum vreo lună am instalat PostgreSQL folosind cea mai banală "metodă": am făcut click pe itemul "Ubuntu Software Center" din panoul grafic "Favorites" şi odată conectat, am tastat 'postgresql' în bara de căutare, am selectat pachetul din lista redată şi am făcut click pe butonul "Install"; mi s-a cerut să tastez şi o anumită parolă, după care nu a trebuit să mai completez vreo altă fereastră de dialog. Simplu, rapid şi fără nicio problemă - cum să nu fii mulţumit?!

Fiind deja familiarizat cu limbajul SQL (fiindcă am folosit mult timp MySQL), am experimentat curajos vreo câteva zile, răsfoind documentaţia pachetului şi unele tutoriale. La un moment dat, am luat seama şi la ce nu trebuie făcut, dacă vrei să eviţi apariţia ulterioară a unor probleme de securitate - şi am constatat că deja fusesem prea "curajos" (sau neştiutor): aspectul esenţial al situaţiei constă în faptul că adăugasem contul meu 'vb' drept rol de "superuser" al PostgreSQL (pe lângă cel standard existent, căruia i-am mai setat şi o parolă).

N-am fost în stare să reconfigurez şi să corectez "una-două" lucrurile, încât am apelat la metoda clasică de remediere: am accesat "Center", am identificat pachetul şi am făcut click pe butonul "Remove"; apoi am reinstalat 'postgresql'. Probabil că lucrurile ar fi decurs normal şi aşa, dacă nu aş fi şters în prealabil userul 'postgres' - gândindu-mă că eu i-am setat parola şi deci tot eu ar trebui, înainte de reinstalare, să o elimin (dacă se poate), aşteptându-mă ca şi la re-instalare să se re-creeze automat userul 'postgres' (cum zice manualul despre instalare).

Dar n-a fost deloc cum mă aşteptam; n-am mai reuşit să startez serverul PostgreSQL. Bineînţeles că am căutat pe Internet (pentru "unknown user: postgres"), unde se ştie că ai satisfacţia de a descoperi că mulţi au păţit-o ca şi tine (doar că fiecare a păţit-o în felul său…); nu am găsit o soluţie care să meargă "de-a gata" pentru situaţia mea, dar am putut să-mi dau seama că totuşi, nu există o cale de rezolvare mai simplă şi mai sigură decât cea pe care deja o parcursesem: elimină vechea instalare şi apoi, reinstalează.

Dar uneori (ca în cazul de aici) trebuie să te asiguri că se şterge tot ceea ce ţine de instalarea anterioară; eu "eliminasem" folosind butonul "Remove" din interfaţa grafică oferită de "Ubuntu Software Center", dar aşa nu poţi şi controla, ce se şterge şi ce nu. Folosind în schimb, apt-get de pe linia de comandă (în loc de butoane şi meniuri) vedem exact ce se elimină şi ce nu, respectiv ce se instalează, unde şi cu ce setări principale:

sudo apt-get purge postgresql*
sudo rm -r /var/lib/postgresql  ## forţează ştergerea bazei de date existente ##
sudo apt-get install postgresql
Creating new cluster 9.5/main ...
  config /etc/postgresql/9.5/main
  data   /var/lib/postgresql/9.5/main  ## PURGE nu va şterge datele existente ##
  locale en_US.UTF-8
  socket /var/run/postgresql
  port   5432

Prin prima din secvenţa de trei comenzi redată mai sus, se şterg toate fişierele cu sufixul 'postgresql' (inclusiv, fişierele de configurare - exceptate de varianta "apt-get remove") şi totodată, se afişează pe ecran ce fişiere nu sunt şterse (fiind protejate: numai proprietarul lor le poate scrie, sau şterge); astfel, am fost atenţionat că /var/lib/postgresql/ nu va fi şters şi atunci, am şters direct (prin a doua comandă) directorul respectiv.

A treia comandă asigură instalarea PostgreSQL, furnizând pe parcurs unele informaţii foarte utile: unde găsim fişierele de configurare, unde este creată iniţial baza de date, ce sistem de codificare a caracterelor se foloseşte, etc. După terminarea instalării, ne putem convinge că într-adevăr, /var/lib/postgresql/ nu va putea fi scris (sau şters) decât de către proprietar:

vb@Home:~$ ls -l /var/lib/postgresql/
total 4
drwxr-xr-x 3 postgres postgres 4096 nov  7 06:09 9.5

Rezultatul afişat spune că subdirectorul 9.5/ (de pe calea indicată în comandă) are ca proprietar user-ul 'postgres'; acesta are drepturile 'rwx' (de citire, scriere, execuţie), iar toţi ceilalţi "useri" au drept de citire şi drept de execuţie (nu şi de scriere, sau ştergere) asupra directorului respectiv.

5. Serverul PostgreSQL

Presupunem că vom fi instalat deja (şi corect), PostgreSQL - fie accesând (prin click) "Ubuntu Software Center", fie folosind apt-get din linia de comandă, fie descărcând pachetul executabil potrivit (sau pe cel "cod-sursă") oferit la postgresql şi urmând indicaţiile asociate acestuia.

După instalare, putem observa că s-a creat un nou user, cu numele 'postgres', descris ca "PostgreSQL administrator", având ca director de lucru /var/lib/postgresql:

vb@Home:~$ cat /etc/passwd | grep post
postgres:x:125:134:PostgreSQL administrator,,,:/var/lib/postgresql:/bin/bash
vb@Home:~$ sudo cat /etc/shadow | grep post
postgres:*:17477:0:99999:7:::

'postgres' nu este un user obişnuit, în sensul că el nu se poate loga interactiv (furnizând numele său şi parola) - fiindcă în fişierul /etc/shadow i s-a înregistrat '*' în loc de parolă. Valoarea 17477 de pe câmpul următor parolei reprezintă data înregistrării contului (sau data ultimei "schimbări de parolă", dacă este cazul), ca număr de zile de la 1.01.1970 (în multe limbaje, avem câte o funcţie care transformă acest număr în dată calendaristică; pentru 17477 obţinem data 7.11.2017).

Dar ce înseamnă că /var/lib/postgresql este "directorul de lucru" al lui 'postgres'?

vb@Home:~/envPy3$ echo ~
/home/vb  # directorul de lucru al userului 'vb'
vb@Home:~/envPy3$ sudo echo ~postgres
[sudo] password for vb: 
/var/lib/postgresql  # directorul de lucru al userului 'postgres'
vb@Home:~/envPy3$ sudo ls -la ~postgres
# ... ... 
drwxr-xr-x  3 postgres postgres 4096 nov  7 06:09 9.5
-rw-------  1 postgres postgres  191 nov 12 11:11 .bash_history
-rw-------  1 postgres postgres  545 nov 12 11:07 .psql_history

În "directorul de lucru" (pentru care Bash prevede scurtătura '~') găsim fişierele "ascunse" .bash_history şi .psql_history, din care vom putea recupera când am avea nevoie, comenzi tastate pe linia de comandă (respectiv în shell-ul psql) în sesiuni de lucru anterioare ale userului respectiv (iar combinaţia de taste 'CTRL + r' recuperează comenzile prin autocompletare).

Mai putem observa că serverul PostgreSQL este pornit, fiind deja lansate câteva procese (redăm aici doar unul, separând unele câmpuri pe câte o linie):

vb@Home:~$ ps aux | grep postg  # 'ps' raportează procesele active
postgres  1026  0.0  0.2 296232 24480 ?        S    06:19   0:00 
          /usr/lib/postgresql/9.5/bin/postgres  # createdb(), etc.; psql; pg_ctl
        -D /var/lib/postgresql/9.5/main  # directorul bazei de date (implicit)
        -c config_file=/etc/postgresql/9.5/main/postgresql.conf  # fişiere de configurare

Directorul /usr/lib/postgresql/9.5/bin conţine un anumit număr de programe utilitare (executabile care pot fi apelate direct din linia de comandă), între care şi postgres (serverul PostgreSQL); unele dintre acestea "dublează" comenzi SQL obişnuite cu bazele de date (de exemplu createdb, dropdb, createuser, pg_dump, etc.), iar altele facilitează anumite operaţii administrative (initdb, şi altele). Programul psql permite lucrul interactiv (de exemplu, tastezi o comandă SQL şi obţii pe ecran rezultatul execuţiei acesteia); programul pg_ctl facilitează operarea serverului (iniţializare, start, stop şi anumite operaţii de control).

Iată un experiment iterativ, instructiv din multe puncte de vedere:

vb@Home:~$ pg_ctl status  # starea curentă a serverului
pg_ctl: command not found

Comenzile de executat sunt găsite în subdirectoare 'bin/' pe căile specificate în variabila de mediu globală PATH (iar /usr/lib/postgresql/9.5/bin nu figurează în PATH). Să indicăm atunci, calea absolută a programului:

vb@Home:~$ /usr/lib/postgresql/9.5/bin/pg_ctl status
pg_ctl: no database directory specified and environment variable PGDATA unset
Try "pg_ctl --help" for more information.

Neavând setată variabila PGDATA, să adăugăm opţiunea "-D" pentru directorul bazei de date:

vb@Home:~$ /usr/lib/postgresql/9.5/bin/pg_ctl status -D /var/lib/postgresql/9.5/main
pg_ctl: could not open PID file "/var/lib/postgresql/9.5/main/postmaster.pid": 
Permission denied

Putem vedea (cu ls -la) că postmaster apare în ..9.5/bin ca un "symlink" la serverul postgres. Dar userului 'vb' nu i se permite să deschidă fişierul PID (care conţine numărul asociat procesului aflat în execuţie); încercăm ca user 'root' (folosind sudo):

vb@Home:~$ sudo /usr/lib/postgresql/9.5/bin/pg_ctl status \
                -D /var/lib/postgresql/9.5/main
pg_ctl: cannot be run as root
Please log in (using, e.g., "su") as the (unprivileged) user that will own the server process.

Am greşit: sudo command execută comanda indicată, cu privilegiile lui 'root' - dar nu despre execuţia unei comenzi pe care doar 'root' o poate executa, era vorba, ci de userul care are dreptul de a deschide fişierul PID; indicăm (după mesajul ajutător afişat) userul 'postgres':

vb@Home:~$ sudo -u postgres /usr/lib/postgresql/9.5/bin/pg_ctl status \
                            -D /var/lib/postgresql/9.5/main
pg_ctl: server is running (PID: 1110)
/usr/lib/postgresql/9.5/bin/postgres "-D" "/var/lib/postgresql/9.5/main" 
"-c" "config_file=/etc/postgresql/9.5/main/postgresql.conf"

Deci serverul este în starea "running" şi are PID=1110 - ceea ce putem verifica şi direct, inspectând conţinutul fişierului "postmaster.pid":

vb@Home:~$ sudo  cat /var/lib/postgresql/9.5/main/postmaster.pid
1110  # (PID: 1110)
## ... (alte informaţii despre procesul respectiv)

Putem verifica mai departe şi că stoparea serverului (pg_ctl stop) atrage ştergerea fişierului "postmaster.pid"; în general, testarea existenţei fişierului PID într-un moment sau altul permite unui program să decidă în cursul execuţiei startarea sau stoparea procesului respectiv.

(5*) Interfeţe de programare cu baze de date

De prin 1990 s-a pus la punct specificaţia generică DBI - "Database (independent) Interface" - pentru a accesa baze de date în mod unitar (indiferent de RDBMS), prin programe într-un limbaj sau altul; exportarea datelor de exemplu în R (sau în Python, etc.) este dorită între altele, pentru a facilita analiza statistică a acestora (şi sintetizarea grafică).

Conform DBI, funcţia dbConnect() va constitui un obiect propriu limbajului asumat, prin care se va asigura conectarea la severul RDBMS respectiv, putând accesa o anumită bază de date; funcţiile dbListTables() şi dbListFields() produc o listă a tabelelor existente în baza de date, respectiv o listă a câmpurilor unui tabel; dbReadTable() extrage datele din tabelul indicat, într-o structură de date proprie limbajului respectiv, iar dbWriteTable() face operaţia inversă; se pot formula cereri SQL prin dbGetQuery(), dbSendQuery() şi dbFetch(), obţinând rezultatele acestora ca obiecte proprii limbajului respectiv. Tipurile elementare de date specifice RDBMS sunt convertite în limbajul asumat, câştigând şi în ceea ce priveşte acurateţea calculului numeric.

În R avem de exemplu, pachetele RPostgreSQL, RMySQL şi RSQLite pentru a interfaţa (folosind DBI) cu PostgreSQL, MySQL, respectiv SQLite. Redăm o sesiune de lucru într-o consolă R prin care obţinem într-un obiect 'data.frame' date din baza de date MySQL "slightchess" (v. Modelarea şi înregistrarea datelor, unde drept interfaţă DBI foloseam modulul Python MySQLdb):

vb@Home:~/slightchess/Doc/SUPPLY$ R -q
> library(RMySQL)
Loading required package: DBI
> conn <- dbConnect(MySQL(), 
                    user='vb', password='asdf', host='localhost', 
                    dbname='slightchess')
> dbListTables(conn)
 [1] "auth_group"                      "auth_group_permissions"         
 [3] "auth_permission"                 "django_admin_log"               
 [5] "django_content_type"             "django_session"                 
 [7] "games_game"                      "games_land"                     
 [9] "games_partner"                   "games_selector"                 
[11] "games_selector_groups"           "games_selector_user_permissions"
> dbListFields(conn, "games_game")
[1] "id"         "pgn"        "partner_id" "coach_id"   "comment"   
[6] "mark_pos"   "pgn_hash"  
> dbReadTable(conn, "games_game") -> games_df
> str(games_df)
'data.frame':	1179 obs. of  7 variables:
 $ id        : int  1231 1232 1233 1234 1236 1237 1239 1240 1241 1242 ...
 $ pgn       : chr  "[Event \"InstantChess\"]\n[White \"vlad.bazon\"]\n # etc. ...
 $ partner_id: int  1605 1699 1637 1593 2185 2017 2133 2035 1654 2139 ...
 $ coach_id  : int  1 1 1 1 1 1 1 1 1 1 ...
 $ comment   : chr  "" "" "" "" ...
 $ mark_pos  : num  0 0 0 0 0 0 0 0 0 0 ...
 $ pgn_hash  : chr  "0b1f5c07daac42de2e159e4dfe06c54d" ...
> dbDisconnect(conn)

Dacă ne-ar conveni baza de date MySQL existentă, am putea imediat să transferăm datele respective (păstrând structura acestora) într-o bază de date PostgreSQL: încărcăm în sesiunea curentă şi pachetul RPostgreSQL, creem o conexiune 'conn1' cu o bază de date PostgreSQL şi apelăm dbWriteTable(conn1, games_tabel, games_dataframe), unde 'games_tabel' este unul sau altul dintre numele tabelelor MySQL obţinute mai sus prin dbListTables(conn), iar 'games_dataframe' este obiectul 'data.frame' asociat (precum 'games_df' rezultat în exemplificarea de mai sus pentru dbReadTable(conn, "games_game")).

Dar (în spiritul reluării de la capăt) intenţionăm să simplificăm structura bazei de date faţă de "slightchess", încât abandonăm aici ideea exprimată mai sus de a transfera datele din MySQL în PostgreSQL, prin intermediul unui program R.

(5**) Interfaţa Python psycopg2; conturi Linux şi roluri PostgreSQL

Django implică Python (nu R), încât este firesc să ne punem la dispoziţie şi o interfaţă "DBI" în Python3, pentru PostgreSQL; instalăm în mediul virtual "envPy3" modulul psycopg2:

vb@Home:~$ cd envPy3/
vb@Home:~/envPy3$ source bin/activate
(envPy3) vb@Home:~/envPy3$ pip install psycopg2

Putem testa funcţionarea pachetului instalat (înainte de a formula vreun exemplu de utilizare similar celui de mai sus pentru RPostgreSQL) folosind modulul încorporat tests - angajând de exemplu următorul program tipic:

# envPy3/DOC/copg2_test.py
# presupune că userul Linux curent poate accesa DB 'psycopg2_test'
from psycopg2 import tests
tests.unittest.main(defaultTest='tests.test_suite')

Dar pentru execuţia acestui program trebuie ca în prealabil, să instituim baza de date cerută şi anume, astfel încât userul Linux curent (userul local "vb") să o poată accesa; este instructiv un experiment iterativ (similar celui prin care am lansat pg_ctl, în §5), pentru a ajunge să executăm programul de mai sus (dar aici nu mai redăm şi acest experiment).

Dificultatea care apare când începem să folosim practic PostgreSQL (vizată şi la §4*) provine din confuzia creată de termenul postgres: acesta are trei sensuri diferite, desemnând fie un user Linux local (cont creat pe calculatorul care găzduieşte PostgreSQL), fie rolul de administrator (sau "superuser") al sistemului PostgreSQL (analog într-o anumită măsură, cu userul privilegiat root din Linux), fie chiar baza de date instituită iniţial.

În spatele acestei situaţii nu se află vreo dorinţă de confuzie, ci metoda "peer authentication", implicată de PostgreSQL pentru cazul când este conectat chiar de pe calculatorul (sistem Linux) care îl găzduieşte: se află numele subdirectorului din /home corespunzător utilizatorului Linux curent şi se foloseşte tocmai acest nume drept "user PostgreSQL" (sau "rol") şi drept nume de bază de date. Pentru fiecare rol înregistrat, trebuie să existe un user Linux corespunzător (nu neapărat de acelaşi nume, existând o funcţie care poate asocia numele respective); acest user Linux (şi în principiu - pentru a proteja datele - numai el şi 'postgres') va putea accesa baza de date creată în numele său, operând însă conform privilegiilor stabilite la crearea rolului asociat lui.

Pentru a executa programul "copg2_test.py" redat mai sus, putem proceda astfel: întâi, folosind sudo intrăm pe contul userului Linux 'postgres'; acesta îşi va putea folosi rolul de administrator PostgreSQL, încât va putea crea baza de date "psycopg2_test" şi deasemenea, va putea asocia un rol PostgreSQL userului Linux curent 'vb':

vb@Home:~/envPy3$ sudo -i -u postgres
postgres@Home:~$ createdb psycopg2_test
postgres@Home:~$ createuser vb -P --interactive
Enter password for new role: 
Enter it again: 
Shall the new role be a superuser? (y/n) n
Shall the new role be allowed to create databases? (y/n) n
Shall the new role be allowed to create more new roles? (y/n) n
postgres@Home:~$ exit
logout
vb@Home:~/envPy3$ 

Pentru verificare, putem accesa shell-ul psql din contul lui 'postgres', pentru a lista bazele de date (regăsim şi "psycopg2_test") şi rolurile existente:

vb@Home:~/envPy3$ sudo -u postgres psql
psql (9.5.9)
Type "help" for help.
postgres=# \l
                                    List of databases
     Name      |  Owner   | Encoding |   Collate   |    Ctype    |   Access privileges   
---------------+----------+----------+-------------+-------------+-----------------------
 postgres      | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 | 
 psycopg2_test | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 | 
 template0     | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 | =c/postgres          +
 template1     | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 | =c/postgres          +
               |          |          |             |             | postgres=CTc/postgres
(4 rows)
postgres=# \du
                                   List of roles
 Role name |                         Attributes                         | Member of 
-----------+------------------------------------------------------------+-----------
 postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS | {}
 vb        |                                                            | {}
postgres=# \q
vb@Home:~/envPy3$ 

Rolul 'vb' nu are privilegii (fiindcă am răspuns cu "n" la întrebările afişate anterior prin rularea comenzii createuser): nu poate crea baze de date, nici alte roluri, etc. Să observăm că "Owner" pentru cele patru baze de date listate mai sus (inclusiv pentru "psycopg2_test") este 'postgres'.

Acum activăm mediul virtual 'envPy3' şi lansăm programul "copg2_test.py":

vb@Home:~/envPy3$ source bin/activate
(envPy3) vb@Home:~/envPy3$ python DOC/copg2_test.py 
...........................s.......................................s. # ETC.
ERROR: test_composite_namespace (psycopg2.tests.test_types_extras.AdaptTypeTestCase)
Traceback (most recent call last):
  # ... ...
  File "/home/vb/envPy3/lib/python3.5/site-packages/psycopg2/tests/test_types_extras.py", ...
    curs.execute("create schema typens;")
psycopg2.ProgrammingError: permission denied for database psycopg2_test
# ... ... ... 
Ran 659 tests in 34.729s
FAILED (errors=2, skipped=64)
(envPy3) vb@Home:~/envPy3$ deactivate
vb@Home:~/envPy3$ 

Avem în final "FAILED", fiindcă două teste (dintre cele vreo 600 de teste efectuate) au dat eroarea "permission denied", anume pentru operaţii de tip "create"; dar este normal să fie aşa: rolul PostgreSQL care a fost definit pentru userul Linux 'vb' din contul căruia am lansat programul de testare este un rol complet ne-privilegiat (nu are permisiunea de a crea fişiere ale bazei de date).

(5***) Investigaţii directe asupra conexiunii şi bazei de date

Înainte de a şterge baza de date 'psycopg2_test' (fiindcă nu vom mai avea nevoie de ea), să vedem cumva cam în ce constă aceasta; fiindcă userul local 'vb' are asociat un rol PostgreSQL, putem folosi shell-ul psql chiar din contul acestuia (nu mai este neapărat necesară, comutarea în contul 'postgres' prin sudo -u postgres):

vb@Home:~/envPy3$ psql
psql: FATAL:  database "vb" does not exist

Eroarea etalată este foarte instructivă!

Ştim că serverul PostgreSQL este pornit (la §5 am verificat prin pg_ctl status că starea curentă este "running"); apelul din linia de comandă psql solicită conectarea noului client 'vb', scopul fiind acela de a accesa date dintre cele existente; dar datele sunt păstrate în fişiere, iar acestea sunt grupate în anumite subdirectoare ale unui director comun reprezentând o "bază de date"; fiindcă pot exista mai multe baze de date, ajungem la necesitatea de a indica şi o bază de date, în scopul conectării la server.

Când sesizează o cerere de conectare, serverul PostgreSQL consultă întâi fişierul de configurare "pg_hba.conf" (de obicei, /etc/postgresql/9.5/main/pg_hba.conf) - pentru a autentifica user-ul de la care provine cererea; în cazul nostru, pentru 'vb' corespunde a doua linie din acest fişier:

# Database administrative login by Unix domain socket
local   all             postgres                           peer
# TYPE  DATABASE           USER            ADDRESS                 METHOD
# "local" is for Unix domain socket connections only
local   all             all                                peer

Linia "local all all peer" autorizează conexiunea pentru orice cont Linux de pe calculatorul care găzduieşte serverul (în particular pentru 'vb'), pentru oricare bază de date, prin metoda peer; dar "peer authentication" - cum am evidenţiat deja la §5** - asociază automat userul local cu o bază de date denumită la fel cu userul respectiv şi cum "database "vb" does not exist", iar pe de altă parte fiindcă nu am explicitat în linia comenzii o bază de date - serverul PostgreSQL a refuzat să creeze conexiunea.

Să repetăm comanda, specificând acum şi baza de date:

vb@Home:~/envPy3$ psql psycopg2_test
psql (9.5.9)
Type "help" for help.
psycopg2_test=> 

La promptul afişat de psql putem tasta comenzi; ne informăm întâi asupra conexiunii create:

psycopg2_test=> \conninfo
You are connected to database "psycopg2_test" as user "vb" 
via socket in "/var/run/postgresql" at port "5432".

Investigând imediat /var/run/postgresql (dintr-un alt terminal, folosind sudo), putem constata că 'postgres' tocmai a creat un director care conţine şi un fişier cu un nume ca "db_21592.stat"; pe de altă parte, prin ps aux | grep postgres putem vedea că procesul 'postgres' are acum şi un subproces (pentru 'vb' şi baza de date 'psycopg2_test'). Aceste mici investigaţii sunt în măsură să evidenţieze mecanismul client-server specific sistemului PostgreSQL: serverul (procesul-master, denumit "postmaster" în versiunile mai vechi - nume păstrat numai pentru compatibilitate, fiind acum doar un "symlink" la 'postgres') odată pornit, aşteaptă continuu (la portul 5432, în mod standard) cereri de conexiune şi când acceptă cererea respectivă pentru baza de date indicată, lansează un subproces destinat să gestioneze operaţiile de citire/scriere care urmează să fie făcute de "clientul" respectiv pentru acea bază de date.

Să listăm tabelele (sau "relaţii") existente în baza de date la care a fost conectat 'vb' şi să aflăm subdirectorul corespunzător unuia:

psycopg2_test=> \d+
                       List of relations
 Schema |   Name    | Type  | Owner |    Size    | Description 
--------+-----------+-------+-------+------------+-------------
 public | isolevel  | table | vb    | 0 bytes    | 
 public | test_tpc  | table | vb    | 8192 bytes | 
 public | test_with | table | vb    | 8192 bytes | 
 public | withhold  | table | vb    | 8192 bytes | 
(4 rows)
psycopg2_test=> select pg_relation_filepath('test_with'); 
 pg_relation_filepath 
 base/21592/24194
(1 row)
psycopg2_test=> \q  # "quit" - iese din 'psql'
vb@Home:~/envPy3$ 

Să remarcăm întâi legătura dintre '21592/' de pe calea returnată de funcţia pg_relation_filepath() şi fişierul "db_21592.stat", apărut într-un subdirector din /var/run/postgresql (directorul indicat în urma comenzii /conninfo, redate mai sus). Calea base/21592/ este relativă la "directorul de lucru" (specificat eventual în variabila de mediu PGDATA) şi o putem inspecta:

vb@Home:~/envPy3$ sudo ls -la /var/lib/postgresql/9.5/main/base/21592
total 9692 
drwx------ 2 postgres postgres   12288 nov 15 14:00 .
drwx------ 6 postgres postgres    4096 nov 13 17:54 ..
-rw------- 1 postgres postgres    8192 nov 13 17:54 112
-rw------- 1 postgres postgres    8192 nov 13 17:54 113
-rw------- 1 postgres postgres   57344 nov 13 17:54 12244
# ... ... etc.
-rw------- 1 postgres postgres    8192 nov 13 18:50 24194  #  base/21592/24194
# ... ... etc.
-rw------- 1 postgres postgres     512 nov 13 17:54 pg_filenode.map
-rw------- 1 postgres postgres  122536 nov 15 14:00 pg_internal.init
-rw------- 1 postgres postgres       4 nov 13 17:54 PG_VERSION
vb@Home:~/envPy3$ 

PostgreSQL asociază printr-un catalog intern, câte un identificator numeric ("object identifier", sau "OID") fiecărei baze de date şi fiecărui fişier component, folosind apoi acest OID drept nume de director sau de fişier; baza de date 'psycopg2_test' este reprezentată prin subdirectorul '21592/' (aflat în directorul 'base/', alături de subdirectoarele celorlalte baze de date existente), care conţine 9692 de fişiere (am redat câteva mai sus), reprezentând tabelele de date componente, indecşii asociaţi, funcţiile definite pentru datele respective şi multe alte obiecte considerate în PostgreSQL pentru organizarea în memorie a datelor şi pentru gestionarea acestora.

Bineînţeles - ne abţinem să ne mai aventurăm cu "investigaţiile" şi deducerile. Pentru a şterge baza de date, trebuie să intrăm pe contul 'postgres' (fiindcă acesta este "Owner", cum am văzut când am listat bazele de date în §5**):

vb@Home:~/envPy3$ sudo -u postgres psql
postgres=# drop database psycopg2_test;
DROP DATABASE
postgres=# \q
vb@Home:~/envPy3$ 

Ar fi de remarcat că în PostgreSQL baza de date are o structură internă complexă, implicând mult mai multe fişiere şi obiecte decât în MySQL, de exemplu.

6. Baza de date a proiectului Django 'chessg'

În §4 am instituit proiectul Django 'chessg', astfel încât aplicaţiile acestuia să poată fi accesate prin http://py3.hom/; deocamdată dezvoltăm site-ul respectiv - când va fi să-l publicăm, va fi de ales un nume obişnuit în loc de "py3.hom" şi va trebui să înlocuim 'vb' (aflat în grupul 'sudo') cu un user Apache obişnuit (cu minimum de privilegii).

Înfiinţăm o bază de date cu acelaşi nume ca proiectul (ceea ce a devenit o tradiţie) şi prevedem ca user-ul existent 'vb' să aibă privilegiile necesare pentru operaţiile specifice:

vb@Home:~/envPy3$ sudo -u postgres psql
postgres=# create database chessg;
postgres=# grant all privileges on database chessg to vb;
postgres=# \q
vb@Home:~/envPy3$  psql chessg  # verificăm de pe contul 'vb'
chessg=> \l chessg 
                                List of databases
  Name  |  Owner   | Encoding |   Collate   |    Ctype    |   Access privileges   
--------+----------+----------+-------------+-------------+-------------------------
 chessg | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 | =Tc/postgres           +
        |          |          |             |             | postgres=CTc/postgres  +
        |          |          |             |             | vb=CTc/postgres

'postgres' a acordat ca drepturi de acces "Tc" (creare de tabele temporare şi conectare) pentru toţi userii autentificaţi şi "CTc" pentru sine şi pentru 'vb' (apare în plus şi dreptul "C", de a crea tabele, indecşi şi alte obiecte). Privilegiile menţionate vizează carcasa bazei de date; pentru tabelele ce vor fi incluse, privilegiile necesare (pentru selectare, adăugare, inserare, modificare sau ştergere de înregistrări) sunt implicate de specificaţia "all privileges" din comanda GRANT folosită mai sus.

Specificăm proiectului să angajeze PostgreSQL - pentru care Django 1.11 va implica automat, adaptorul psycopg2 - şi să folosească baza de date locală (aflată pe acelaşi sistem cu proiectul) tocmai creată - intervenind pentru aceasta în fişierul "settings.py":

# modificare în ~/envPy3/chessg/chessg/settings.py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'chessg', 'USER': 'vb', 'PASSWORD': 'mypassword',
        'HOST': 'localhost',  # bază de date locală, de accesat prin Internet
        'PORT': '',
    }
}

Desigur, pentru a activa noua configurare operată în "settings.py", trebuie să restartăm serverul Apache (verificând în prealabil, ca în fişierul /etc/apache2/mods-available/wsgi.load să fie activă configurarea 'LoadModule wsgi_module' specifică mediului virtual "envPy3" - v. §4).

În "settings.py" sunt deja prevăzute la INSTALLED_APPS, nişte aplicaţii (django.contrib.admin, django.contrib.auth, etc.); acestea îşi definesc anumite tabele de date, necesare când s-ar accesa aplicaţia respectivă (de exemplu prin http://py3.hom/admin). Pentru a crea tabelele definite în fişierele models.py proprii aplicaţiilor, Django prevede un mecanism de "migrare" (de la o versiune la alta):

vb@Home:~/envPy3$ source bin/activate
(envPy3) vb@Home:~/envPy3$ cd chessg/
(envPy3) vb@Home:~/envPy3/chessg$ python manage.py makemigrations
No changes detected
(envPy3) vb@Home:~/envPy3/chessg$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
# ... etc.
(envPy3) vb@Home:~/envPy3/chessg$

makemigrations creează fişiere ca "0001_initial.py" într-un subdirector migrations/ din directorul fiecărei aplicaţii, reflectând starea curentă a modelelor de date definite de modulul models.py (este instructiv de exemplu, envPy3/lib/python3.5/site-packages/django/contrib/admin/migrations/0001_initial.py); apoi, pe baza acestor noi fişiere, migrate constituie secvenţele de instrucţiuni SQL necesare pentru ca PostgreSQL să creeze (sau să re-creeze) tabelele respective.

Folosind acum psql, putem constata crearea tabelelor corespunzătoare aplicaţiilor specificate în INSTALLED_APPS şi putem lista de exemplu, înregistrările din "django_migrations":

(envPy3) vb@Home:~/envPy3/chessg$ psql chessg
chessg=> \d
                       List of relations
 Schema |               Name                |   Type   | Owner 
--------+-----------------------------------+----------+-------
 public | auth_group                        | table    | vb
 public | auth_group_id_seq                 | sequence | vb
 ... ...
 public | django_migrations                 | table    | vb
 public | django_migrations_id_seq          | sequence | vb
 public | django_session                    | table    | vb
chessg=> select * from django_migrations;
 id |     app      |      name      |     applied            
----+--------------+----------------+--------------
  1 | contenttypes | 0001_initial   | 2017-11-18 08:52:23.455821+02
  2 | auth         | 0001_initial   | 2017-11-18 08:52:24.803761+02
  3 | admin        | 0001_initial   | 2017-11-18 08:52:25.103139+02
  ... ...
chessg=> \q
(envPy3) vb@Home:~/envPy3/chessg$ deactivate
vb@Home:~/envPy3/chessg$

Modelele definite în fişierele models.py din subdirectoarele aferente aplicaţiilor existente vor fi comparate cu conţinutul fişierelor indicate în coloana "name" din tabelul django_migrations şi dacă este cazul, se vor adăuga în acest tabel noi fişiere de "migrare" (corespunzător ultimei versiuni a definiţiei Django-Python a structurii tabelelor).

(6*) Adăugarea tabelului reprezentând un obiect R 'dataframe'

Aplicaţia Django pe care urmează să o înfiinţăm - o vom numi 'games' - va trebui să-şi definească (în fişierul "models.py" creat de django-admin în momentul constituirii ei) tabelele relaţionate necesare pentru a reflecta conţinutul unei colecţii PGN de partide de şah.

Să încercăm acest procedeu: având deja datele respective (v. §2), creem tabelele PostgreSQL corespunzătoare, apoi instituim aplicaţia Django care le-ar exploata şi cerem lui django-admin să inspecteze datele existente şi să creeze automat modelele Python corespunzătoare acestora.

La §2 am constituit obiectul R 'games' (de tip "data.frame"), reprezentând conţinutul unui fişier PGN (o colecţie de partide de şah); să ignorăm deocamdată, faptul că toate datele sunt într-un acelaşi obiect (sau tabel) şi nu cum s-ar cuveni, în tabele relaţionate (unul pentru jucători, unul pentru naţionalităţi, unul pentru partide, etc.).

Folosind pachetul RPostgreSQL, înscriem datele din acest data.frame într-un tabel 'games_tpgn' al bazei de date 'chessg':

require("RPostgreSQL")
conn <- dbConnect(PostgreSQL(), 
                  user='vb', password='mypassword', host='localhost', 
                  dbname='chessg')
dbWriteTable(conn, 'games_tpgn', games)
dbDisconnect(conn)

Închidem consola R în care am operat mai sus (sau, deschidem un alt terminal) şi verificăm:

vb@Home:~/slightchess/Doc/SUPPLY$ psql chessg
chessg=> \d+ games_tpgn 
                       Table "public.games_tpgn"
   Column   | Type | Modifiers | Storage  | Stats target | Description 
------------+------+-----------+----------+--------------+-------------
 row.names  | text |           | extended |              | 
 White      | text |           | extended |              | 
 Black      | text |           | extended |              | 
 Result     | text |           | extended |              | 
 WhiteIFlag | text |           | extended |              | 
 BlackIFlag | text |           | extended |              | 
 MoveList   | text |           | extended |              | 

Coloana "row.names", moştenită de la data.frame (unde indică în mod implicit, numărul de ordine al liniei de date), va putea juca rolul de "primary key"; dar regula în PostgreSQL este de a denumi câmpurile folosind litere mici (altfel, referirea lor necesită încadrarea numelor între ghilimele), iar caracterele speciale sunt de evitat (de exemplu, '.' este folosit ca de obicei, drept selector de obiect - cum se vede şi mai sus: public.games_tpgn selectează games_tpgn din structura "public"). Redenumim măcar prima coloană şi apoi facem încă un mic test:

chessg=> alter table games_tpgn rename column "row.names" to "id";
ALTER TABLE
chessg=> select id, "White", "Black" from games_tpgn where cast(id as integer) <= 2; 
 id |   White    |   Black    
----+------------+------------
 1  | Andrei1    | vlad.bazon
 2  | андрей чер | vlad.bazon
(2 rows)

RPostgreSQL a înscris toate valorile ca "text", în tabelul creat; dar se vede în exemplul de mai sus, dezavantajul de a avea "text" pe cheia primară: a trebuit să folosim şi funcţia cast() (clauza where id <= '2' ne dădea '1', '2' precum şi '10..199' - toate fiind mai mici în ordine lexicografică decât '2').

Deci am avea de făcut nişte reparaţii, folosind o serie de comenzi SQL ALTER TABLE (modificând tipuri şi denumiri de câmpuri); apoi, după ce înfiinţăm aplicaţia, am putea obţine "gratis" fişierul 'models.py' lansând "manage.py inspectdb games_tpgn" (aceasta inspectează datele din tabel şi decide cumva ce obiect Python să constituie în 'models.py', pentru fiecare coloană). Dar de obicei, aceste modele (apărute automat) trebuie apoi, retuşate manual (mai ales dacă am avea mai multe tabele, relaţionate între ele); sunt de întâmpinat şi unele capcane sau subtilităţi, legate de mecanismul de migrare (care este inhibat de inspectdb).

Procedeul evidenţiat mai sus (mizând pe generarea automată a modelelor necesare), mai degrabă complică lucrurile… Abandonăm acest experiment şi revenim la procedeul obişnuit.

7. Parcursul primar al lucrului, în Django

Instituim aplicaţia (prin manage.py startapp), definim (manual!) structura tabelelor necesare ei în models.py şi invocăm makemigrations şi migrate pentru crearea în baza de date a tabelelor corespunzătoare definiţiilor respective. Datele (reprezentând o colecţie de partide de şah, în cazul nostru) urmează să fie inserate în tabelele respective fie din exteriorul aplicaţiei (folosind direct SQL), fie în cursul executării aplicaţiei (prin funcţiile prevăzute în views.py, admin.py etc.).

Activăm mediul virtual 'envPy3' şi înfiinţăm aplicaţia 'games':

vb@Home:~/slightchess/Doc/SUPPLY$ cd ~/envPy3/chessg/
vb@Home:~/envPy3/chessg$ source ../bin/activate
(envPy3) vb@Home:~/envPy3/chessg$ ls
chessg  manage.py
(envPy3) vb@Home:~/envPy3/chessg$ python manage.py startapp games
(envPy3) vb@Home:~/envPy3/chessg$ ls games
admin.py  apps.py  __init__.py  migrations  models.py  tests.py  views.py
(envPy3) vb@Home:~/envPy3/chessg$ 

Aplicaţia respectivă este reprezentată în principal prin fişierele apărute în chessg/games/ (listate mai sus, prin comanda ls); urmează să completăm aceste fişiere, definind interfaţa Python cu baza de date (în models.py), interfaţa cu utilizatorul aplicaţiei (în views.py, admin.py), etc.

Pentru a şi activa aplicaţia instituită, avem de adăugat 'games' în lista INSTALLED_APPS din fişierul "settings.py". Apoi, restartând Apache, constatăm că http://py3.hom/ funcţionează.

Pentru a putea utiliza interfaţa grafică de administrare a proiectului (instituită de aplicaţia django.contrib.admin), trebuie să creem un user administrativ - folosind django-admin createsuperuser; accesând apoi http://py3.hom/admin şi tastând în casetele "Username" şi "Password" valorile setate pentru superuser, obţinem:

Link-urile funcţionează, dar pagina redată este nestilată: "The requested URL /static/admin/css/base.css was not found on this server" (browserul nu a reuşit să obţină fişierul de stilare asociat paginii); pentru îndreptare avem de făcut mai mulţi paşi, implicând "settings.py" dar şi fişierul de configurare a gazdei Apache (constituit în §4).

În "settings.py" găsim la sfârşit:

# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.11/howto/static-files/
STATIC_URL = '/static/'

De regulă, fişierele statice trebuie deservite de către Apache (direct, nu prin WSGI); pentru aceasta, inserăm în "envPy3.conf" (definit în §4) directiva:

Alias /static/ /home/vb/envPy3/chessg/static/

prin care explicităm STATIC_URL ca fiind calea absolută la subdirectorul static/ (care urmează să fie creat) al proiectului. Pentru constituirea acestui subdirector, definim în "settings.py":

STATIC_ROOT = os.path.join(BASE_DIR, 'static')

şi apoi invocăm django-admin collectstatic - care va copia fişierele "statice" (de stiluri CSS, de funcţii javaScript, de imagini şi de fonturi) existente în directoarele aplicaţiilor din proiect, într-un subdirector static/ al acestuia. Restartând Apache, URL-urile /static/admin/css/ etc. vor putea fi accesate acum de către browser, pentru a completa redarea paginii.

Mai departe, rămâne să definim models.py, să facem accesibile modelele respective din pagina de administrare a proiectului, să asociem diversele cereri (ca http://py3.hom/get_game_14) cu funcţii care să extragă datele necesare şi să returneze o pagină HTML corespunzătoare, etc.

Ne concentrăm aici pe cum facem una sau alta (având în vedere scopul de instruire pe care ni l-am asumat). Dar - mai ales, dacă ar fi vorba de o aplicaţie Web "reală", vizând interesele unei categorii mai largi de utilizatori - chestiunea de bază este nu cum, ci DE CE facem una sau alta; nu avem de inventat tabele, câmpuri şi formulare, ci de interpretat cu bun simţ interesele pe care am vrea să le deservim.

(7*) Direcţionările de bază ale unui model

Considerăm întâi un singur model, destul de simplu:

# în fişierul ~/envPy3/chessg/games/models.py 
from django.db import models  # din ~/envPy3/lib/python3.5/site-packages/django/db/

class Iflag(models.Model):  # a răsfoi fişierul models/base.py
  name = models.CharField(max_length=64)
  code = models.CharField(max_length=2, primary_key=True)

  def __str__(self):  # Ce reprezentare textuală vrem, pentru un obiect 'Iflag'
    return self.name

Răsfoirea din când în când a codului sursă pus la dispoziţie sporeşte perspectiva şi percepţia lucrurilor şi te poate scoate eventual, din orice încurcătură ivită pe parcursul dezvoltării propriei aplicaţii.

Iflag modelează valorile tag-urilor "WhiteIFlag" şi "BlackIFlag" din fişierul PGN (preluat de la //instantchess.com; v. §1); de exemplu, putem avea obiectul {name: 'România', code: 'RO'}.

Verificăm dacă nu avem cumva vreo greşeală; apoi, aplicăm mecanismul de migrare:

(envPy3) vb@Home:~/envPy3/chessg$ python manage.py check
System check identified no issues (0 silenced).
(envPy3) vb@Home:~/envPy3/chessg$ python manage.py makemigrations games
Migrations for 'games':
  games/migrations/0001_initial.py
    - Create model Iflag
(envPy3) vb@Home:~/envPy3/chessg$ python manage.py migrate games
Operations to perform:
  Apply all migrations: games
Running migrations:
  Applying games.0001_initial... OK

Cu manage.py sqlmigrate games 0001 putem vedea secvenţa de comenzi SQL care a fost aplicată pentru crearea tabelului "games_iflag", asociat modelului Iflag:

BEGIN;
-- Create model Iflag
CREATE TABLE "games_iflag" ("name" varchar(64) NOT NULL, 
                            "code" varchar(2) NOT NULL PRIMARY KEY);
CREATE INDEX "games_iflag_code_5cb727a8_like" 
       ON "games_iflag" ("code" varchar_pattern_ops);
COMMIT;

Înregistrăm modelul în aplicaţia admin (asigurând administrarea datelor acestuia):

# în fişierul ~/envPy3/chessg/games/admin.py 
from django.contrib import admin
from games.models import Iflag
admin.site.register([Iflag, ])

Restartând Apache, putem insera (şi edita) date folosind interfaţa de administrare, de exemplu accesând direct modelul respectiv, prin http://www.py3.hom/admin/games/iflag/.

8. Modelarea datelor aplicaţiei 'games'

Păstrăm modelul 'Iflag' introdus mai sus şi adăugăm 'Player' pentru a reprezenta un jucător şi 'Game', reprezentând o partidă de şah:

from django.db import models

class Iflag(models.Model):
  name = models.CharField(max_length=64)
  code = models.CharField(max_length=2, primary_key=True)
  def __str__(self):
    return self.name

class Player(models.Model):
  name = models.CharField(max_length=32, primary_key=True)
  iflag = models.ForeignKey(Iflag, on_delete=models.CASCADE)
  def __str__(self):
    return self.name

class Game(models.Model):
  pgn = models.TextField()
  white = models.ForeignKey(Player, on_delete=models.CASCADE, related_name="whites")
  black = models.ForeignKey(Player, on_delete=models.CASCADE, related_name="blacks")
  result = models.CharField(max_length=8)
  class Meta:
    unique_together = ("white", "black", "pgn")
  def __str__(self):
    return "%s - %s (%s)" % (self.white, self.black, self.result)

Tabelul PostgreSQL games_player va fi indexat după câmpul "name" (declarat ca "primary_key" în clasa Player), pe care vom înregistra numele jucătorilor (de cel mult 32 de caractere); câmpul "iflag" indică obiectul Iflag corespunzător ţării de care aparţine jucătorul respectiv.

În Game nu am declarat cheia primară, încât Django va adăuga automat un câmp "id" pentru aceasta. În tabelul games_game va fi de înregistrat lista mutărilor partidei (pe câmpul "pgn") dintre jucătorii Player referiţi prin câmpurile "white" şi "black"; specificaţia "related_name" adăugată pe aceste două chei asigură (şi simplifică) o relaţionare inversă (de la obiectul referit, la obiectele care îl referă): dacă P instanţiază un obiect "Player", atunci P.blacks.all() ne va da lista obiectelor "Game" în care câmpul "black" referă P (analog, P.whites.all()).

Specificaţia on_delete=models.CASCADE asigură propagarea cuvenită a "ştergerii": pentru ştergerea unui jucător Player, vor fi şterse în prealabil toate obiectele Game care îl referă.

Specificaţia unique-together adăugată clasei Game ar trebui să prevină înscrierea în tabelul games_game a două partide identice (aceiaşi parteneri şi acelaşi "pgn").

unique va fi "tradusă" într-o anumită instrucţiune SQL asupra tabelului, în timp ce __str__() doar furnizează spre exterior o reprezentare textuală convenţională a obiectului Python respectiv - de aceea am preferat să montăm întâi class Meta şi apoi __str__ (în definiţia modelului Game). De altfel, această ordine (neobligatorie totuşi) pare a fi respectată în toate modelele definite în codul-sursă Django - ţinând deci de "bunele practici".

Ne asigurăm de corectitudinea definiţiilor de mai sus şi creem tabelele şi fişierul de migrare:

(envPy3) vb@Home:~/envPy3/chessg$ python manage.py check
System check identified no issues (0 silenced).
(envPy3) vb@Home:~/envPy3/chessg$ python manage.py makemigrations games
Migrations for 'games':
  games/migrations/0002_auto_20171124_0817.py
    - Create model Game
    - Create model Player
    - Add field black to game
    - Add field white to game
    - Alter unique_together for game (1 constraint(s))
(envPy3) vb@Home:~/envPy3/chessg$ python manage.py migrate games
Operations to perform:
  Apply all migrations: games
Running migrations:
  Applying games.0002_auto_20171124_0817... OK
(envPy3) vb@Home:~/envPy3/chessg$ 

După ce adăugăm Player şi Game în "admin.py" şi restartăm Apache, putem folosi interfaţa de administrare a proiectului pentru a experimenta eventual, cu modelele create (adăugând sau ştergând înregistrări; în particular, putem adăuga în Iflag noi ţări).

Se cuvine să observăm că modelele introduse mai sus vizează numai o anumită categorie de fişiere PGN - cele de la "instantchess.com", care prevăd şi tag-uri PGN pentru ţară… Poate că aceasta este totuşi în avantajul aplicaţiei noastre, dat fiind că pentru fişiere PGN obişnuite (fără tag-uri de ţară) există deja destule aplicaţii de redare în browser.

9. Înregistrarea datelor. Crearea unei comenzi de administrare

Pentru a înregistra (în tabelele create mai sus) ţări, jucători şi partide dintr-un fişier PGN, putem proceda în mai multe moduri (dar ar fi ridicol să folosim în acest scop - funcţionăreşte - interfaţa grafică http://py3.hom/admin/).

În §2 am transformat fişierul PGN iniţial într-un obiect R de tip data.frame (introducând funcţia pgn_dataframe()); am putea proceda acum ca în §6*, transferând din R într-un tabel PostgreSQL - urmând să concepem o secvenţă de comenzi SQL prin care să selectăm din acesta şi să inserăm corespunzător în cele trei tabele existente.

Putem simplifica totuşi lucrurile, evitând tabele intermediare şi lucrul la nivelul bazei de date: transferăm din data.frame într-un fişier CSV şi concepem o funcţie Python care să analizeze acest fişier şi să insereze datele respective în tabele; am avea şi avantajul că această funcţie Python va putea fi uşor integrată în aplicaţie (făcând-o accesibilă din aplicaţia de administrare, sau chiar punând-o la dispoziţia utilizatorilor, printr-un anumit formular de înregistrare).

Tabelul games_iflag trebuie tratat direct (fiindcă în fişierul PGN avem numai codul de câte două caractere, nu şi numele ţării); găsim pe undeva, fişiere CSV conţinând numele ţărilor şi codurile standard corespunzătoare ("envPy3/DOC/country.csv"):

AF,Afghanistan
AX,"Åland Islands"
...
ZM,Zambia
ZW,Zimbabwe

În psql putem folosi comanda \copy, pentru a înscrie datele dintr-un fişier CSV într-un tabel:

# envPy3/DOC/csv.psql
\copy games_iflag(code, name) from '../DOC/country.csv' with(format csv);
insert into games_iflag(name, code) values('??', '??');

Ar fi fost greşit "??" (cu ghilimele): în PostgreSQL ghilimelele sunt folosite pentru identificatori (de exemplu, coloane de tabel) - pentru şiruri de caractere trebuie folosit apostroful.
Desigur, puteam tasta succesiv aceste comenzi direct la promptul psql - dar constituindu-le într-un program obişnuit, ne creem posibilitatea de a le refolosi uşor în diverse alte ocazii.

Executăm fişierul de comenzi redat mai sus:

vb@Home:~/envPy3/chessg$ psql chessg -f "../DOC/csv.psql"
COPY 253
INSERT 0 1
vb@Home:~/envPy3/chessg$

Adăugarea înregistrării "??" previne situaţia în care codul de ţară indicat în PGN nu există, între cele 253 de înregistrări preluate mai sus din "country.csv".

Ne ocupăm acum de Player şi Game. Întâi, de sub R, exportăm obiectul intern "games" de tip "data.frame" obţinut în §2, într-un fişier CSV (envPy3/DOC/games.csv):

vb@Home:~/slightchess/Doc/SUPPLY$ R -q
> ls()
[1] "conn"          "games"         "pgn_dataframe"
> write.csv(games, file="~/envPy3/DOC/games.csv", row.names=FALSE)
> q()

Eliminăm header-ul din fişierul rezultat ("white", "black", "result", "whiteiflag", "blackiflag", "movelist"); am avea de formulat un program care pentru fiecare linie citită din fişier, să caute în games_player valorile din primele două câmpuri ale acelei linii şi să le înscrie dacă nu există (reţinând în oricare caz, valorile cheii primare), să caute apoi în games_iflag valorile din al patrulea şi al cincilea câmp al liniei (procedând analog pasului precedent) şi să înscrie în games_game respectiv cheile primare reţinute în paşii anteriori, rezultatul partidei şi lista de mutări din ultimul câmp al liniei.

Am putea formula un astfel de program ca script Python independent, folosind psycopg2 pentru conectare la baza de date, inserare în tabele, etc.; dar preferăm să-l constituim ca o nouă comandă Django de administrare a proiectului (implicând deci obiectele Python definite în "models.py", în loc de a viza direct tabelele PostgreSQL asociate).

Desigur că pentru a adăuga în Django o comandă de administrare, ne luăm mai întâi după manual; dar repetăm: răsfoirea codului sursă este totdeauna foarte instructivă! În pachetul envPy3/lib/python3.5/site-packages/django/ avem core/management/commands/ iar aici găsim programele Python aferente comenzilor de administrare standard: startproject.py, startapp.py, makemigrations.py, etc. (şi ne putem lua după acestea!).

În directorul games/ al aplicaţiei pe care am înfiinţat-o (prin comanda de administrare startapp) la §7, creem subdirectorul management/ (ca "pachet Python", deci înscriindu-i '__init__.py'), iar în acesta creem subdirectorul commands/, în care vom înscrie programul corespunzător unei noi comenzi de administrare (anume, 'importfromcsv.py'):

vb@Home:~/envPy3/chessg/games$ mkdir management; cd management
vb@Home:~/envPy3/chessg/games/management$ touch __init__.py
vb@Home:~/envPy3/chessg/games/management$ mkdir commands; cd commands
vb@Home:~/envPy3/chessg/games/management/commands$ touch importfromcsv.py

Cum se vede în codurile-sursă ale comenzilor de administrare standard, avem de redefinit funcţia handle(), din clasa 'Command':

# fişierul ~/envPy3/chessg/games/management/commands/importfromcsv.py
from django.core.management.base import BaseCommand, CommandError
from django.db import IntegrityError
from games.models import Iflag, Player, Game
import re

class Command(BaseCommand):
    help = ("Import PGN-date from the given CSV file which don't have header. "
            "Fields: white, black, result, whiteiflag, blackiflag, movelist.")
  
    def add_arguments(self, parser):
        parser.add_argument('file_path')
  
    def handle(self, *args, **options):
        no_flag = Iflag.objects.get(pk="??")
        with open(options['file_path'], encoding='utf-8') as fcsv:
            for line in fcsv:
                fields = [re.sub('"', '', fld) for fld in line.split(',')]
                # self.stdout.write(' '.join(fields[:5]))
                try:
                    fl1 = Iflag.objects.get(code=fields[3])
                except Iflag.DoesNotExist:
                    fl1 = no_flag
                try:
                    p1 = Player.objects.get(name=fields[0])
                except Player.DoesNotExist:
                    p1 = Player.objects.create(name=fields[0], iflag=fl1)
                try:
                    fl2 = Iflag.objects.get(code=fields[4])
                except Iflag.DoesNotExist:
                    fl2 = no_flag
                try:
                    p2 = Player.objects.get(name=fields[1])
                except Player.DoesNotExist:
                    p2 = Player.objects.create(name=fields[1], iflag=fl2)
                gm = Game(pgn=fields[5], white=p1, black=p2, result=fields[2])
                try:  # previne 'duplicate key value violates unique constraint'
                    gm.save()
                except IntegrityError:
                    pass

Restartăm Apache (vrând să verificăm lucrurile din fereastra grafică de administrare); manage.py help importfromcsv afişează şirul explicativ din atributul "help" al comenzii şi explicitează faptul că execuţia comenzii (adică, a funcţiei handle()) necesită adăugarea unui argument 'file_path' (absenţa acestuia produce în mod implicit, o atenţionare):

(envPy3) vb@Home:~/envPy3/chessg$ python manage.py importfromcsv "../DOC/games.csv"
(envPy3) vb@Home:~/envPy3/chessg$

Ar fi de precizat câteva aspecte. În fişierul "games.csv" rezultat mai înainte din R, toate valorile sunt încadrate de ghilimele: "Andrei1","vlad.bazon","0-1","MD","RO", etc. - spre deosebire de valorile din tabelul games_iflag (unde găsim RO, dar nu "RO"); de aceea, după ce am preluat linia curentă din fişier în variabila 'line' şi am împărţit-o în câmpuri prin line.split(',') - am eliminat ghilimelele de pe fiecare câmp 'fld' folosind funcţia sub() din modulul Python (pentru expresii regulate) "re".

'fl1' (analog, 'fl2') este o referinţă la obiectul Iflag în care câmpul 'code' are ca valoare al patrulea câmp al liniei curente (reprezentând "whiteIFlag"); dar dacă obiectul respectiv nu există, atunci 'fl1' este comutat pe obiectul Iflag pe care-l înfiinţasem pentru a reprezenta coduri de ţară dinafara celor existente în fişierul "country.csv". Analog s-ar explica şi secvenţele try ... except pentru 'p1' şi 'p2'.

În cazul când obiectul constituit în final 'gm' există deja în games_game, în mod normal execuţia programului s-ar stopa, antrenând "IntegrityError" (urmare a declaraţiei unique_together = ("white", "black", "pgn") din modelul Game); instrucţiunea pass evită stoparea execuţiei.

Accesând din browser http://www.py3.hom/admin/games/game/, obţinem lista celor 350 de partide înregistrate şi putem face uşor verificările de rigoare; selectând una şi folosind butoanele de acces prevăzute, am deschis ferestre de editare ("Change") pentru cele trei modele (redăm aici trunchiat şi înghesuit):

Am făcut desigur şi o ultimă verificare (dar foarte importantă): am repetat comanda manage.py importfromcsv "../DOC/games_1.csv" pentru un alt fişier CSV (la fel structurat ca şi "games.csv"), în care avem şi unele partide existente şi în primul fişier CSV - constatând că sunt adăugaţi în tabelele respective numai jucătorii neînregistraţi anterior şi numai partidele noi.

Interfaţa de administrare produsă de Django este fără îndoială, în vârful interfeţelor de acest gen (comparând gama de funcţionalităţi, posibilităţile de extensie şi adaptare, concizia şi nivelul de conceptualizare a codului aferent şi al prezentării grafice finale).

10. Investigarea formulării paginii de răspuns (la o cerere fictivă)

Desigur, interfaţa de administrare (de pe care am redat nişte imagini mai sus) deserveşte un singur user - pe "superuser". Să vedem cum am face nişte pagini proprii, accesibile user-ilor obişnuiţi; evităm desigur, să expunem o "reţetă" (cum se obişnuieşte în tutoriale) şi încercăm o deducere directă, printr-un experiment posibil pentru oricare web framework (dacă dispunem de codul-sursă): vedem ce răspuns primim la o cerere sau alta (fictivă, ca să provocăm o pagină de eroare) şi căutăm în codul-sursă cam de unde se trage pagina, sesizând treptat elementele care conduc de la cerere la răspunsul respectiv.

Formulând în bara de adresă http://py3.hom şi respectiv, http://py3.hom/_fictiv, obţinem drept răspuns aceste pagini (referite mai jos prin P1, respectiv P2):

P1 confirmă înfiinţarea proiectului; dar acesta nu conţine vreo aplicaţie proprie (caz în care primim invitaţia "start your first app"), sau - ca în cazul nostru, când am înfiinţat deja aplicaţia chessg/games - aceasta nu a configurat niciun URL.

P2 spune că nu există nicio pagină care să corespundă URL-ului indicat, acesta fiind căutat în chessg.urls; într-adevăr, avem fişierul chessg/urls.py (creat automat în momentul înfiinţării proiectului) şi desigur, putem vedea şi lua în seamă imediat, indicaţiile privitoare la "URL Configuration" pe care le conţine acesta - obţinând şi "reţeta" de lucru dorită: "The `urlpatterns` list routes URLs to views" ne spune să definim în views.py (existent deja, în chessg/games) funcţii pe care să le asociem URL-urilor (de exemplu, pentru "The current path, _fictiv").

Dar putem să nu ne mulţumim cu atâta; să căutăm cuvântul "Congratulations" din P1 în pachetul django (şi apoi analog, pentru "Django tried these URL patterns" conţinut în P2):

vb@Home:~/envPy3/lib/python3.5/site-packages/django$ \
>  grep -r  --exclude-dir=conf  --exclude=*.pyc  "Congratulations" 
views/debug.py:
        "subheading": _("Congratulations on your first Django-powered page."),

Din domeniul de căutare indicat lui grep -r am exclus subdirectorul conf/ fiindcă aici am găsi multe fişiere care ar putea conţine termenii sau şirurile pe care le căutăm (şi care nu ne-ar interesa decât colateral) - anume, conf/locale conţine traducerile în diverse limbi ale mesajelor de eroare, a titlurilor unor pagini, etc.; de exemplu, în django/conf/locale/ro/LC_MESSAGES/django.po găsim textul afişat pe ultima linie din P1, împreună cu traducerea lui în limba română:

msgid ""
"You're seeing this message because you have DEBUG = True in "
"your Django settings file and you haven't configured any URLs. Get to work!"
msgstr ""
"Vedeți acest mesaj deoarece ați setat DEBUG = True în fișierul "
"de setări Django și nu ați configurat nici un URL. La treabă!"

Ca idee, funcţia Django _(msgid) returnează msgstr asociat argumentului prin fişierul "*.po" corespunzător limbii în care vrem să traducem (se vede mai sus invocarea _("Congratulations...")).

Căutarea efectuată mai sus prin grep ne arată că fişierul de unde rezultă până la urmă paginile P1 şi P2 este django/views/debug.py; aici găsim funcţia:

def default_urlconf(request):
    "Create an empty URLconf 404 error response."
    t = DEBUG_ENGINE.from_string(DEFAULT_URLCONF_TEMPLATE)
    c = Context({
        "title": _("Welcome to Django"),  # titlul ferestrei în care apare pagina P1
        "heading": _("It worked!"),
        "subheading": _("Congratulations on your first Django-powered page."),
        "instructions": _(
            "Next, start your first app by running ..."  # ... redare trunchiată
        ),
        "explanation": _(
            "You're seeing this message because you have <code>DEBUG = True<... "
            "Django settings file and you haven't configured any URLs. ... "
        ),
    })
    return HttpResponse(t.render(c), content_type='text/html')

Confruntând pur şi simplu textele "Welcome to Django" (care este titlul ferestrei browser-ului în care apare pagina P1 şi deasemenea, este valoarea cheii "title" din dicţionarul Python construit în funcţia redată mai sus), "It worked!" ş.a.m.d. - ne dăm seama că default_urlconf() este chiar funcţia care produce pagina P1.

În această funcţie - ca şi în funcţia ceva mai complicată technical_404_response(request, exception), care produce pagina P2 - 't' este rezultatul metodei from_string() conţinute de un anumit obiect 'Engine', iar 'c' este un obiect 'Context'; aceste obiecte sunt definite în django/template, cum ne spune linia from django.template import Context, Engine de la începutul fişierului "debug.py".

În clasa 'Engine' din django/template/engine.py găsim această definiţie:

    def from_string(self, template_code):
        """
        Returns a compiled Template object for the given template code,
        handling template inheritance recursively.
        """
        return Template(template_code, engine=self)

Prin urmare 't' din cadrul funcţiei default_urlconf() redate mai sus este obiectul 'Template' rezultat din DEFAULT_URLCONF_TEMPLATE (argumentul cu care s-a apelat from_string() pentru a obţine 't'); iar la sfârşitul fişierului debug.py găsim (redăm trunchiat şi reformatat):

DEFAULT_URLCONF_TEMPLATE = """
<!DOCTYPE html>
<html lang="en"><head>
  ### ... ###
  <meta name="robots" content="NONE,NOARCHIVE"><title>{{ title }}</title>
  ### ... ###
</head>
<body>
<div id="summary">
  <h1>{{ heading }}</h1>
  <h2>{{ subheading }}</h2>
</div>
<div id="instructions">
  <p>  {{ instructions|safe }}  </p>
</div>
<div id="explanation">
  <p>  {{ explanation|safe }}  </p>
</div>
</body></html>
"""

Observăm imediat că elementele cuprinse între câte o pereche de acolade - {{ title }}, etc. - sunt exact cheile dicţionarului Python transmis constructorului Context() pentru a obţine 'c', în cadrul funcţiei default_urlconf(); substituind cheile indicate între perechi de acolade prin valorile asociate acestora în dicţionarul menţionat, obţinem exact codul HTML al paginii P1 - iar această substituire este asigurată de funcţia render() care s-a aplicat în final lui 'c'.

Ca urmare a investigaţiei desfăşurate mai sus, avem o (primă) schemă de lucru pentru a produce cu Django o pagină HTML proprie; definim în games/views.py funcţia care să intre în execuţie în cazul cererii http://py3.hom/_fictiv (şi înregistrăm legătura "cerere - funcţie" în chessg/urls.py):

from django.http import HttpResponse
from django.template import Engine, Context
def my_fictiv(request):
    t = Engine.from_string(MY_TEMPLATE)
    c = Context({
        "title": "My_title",
        # ... #
    })
    return HttpResponse(t.render(c), content_type='text/html')

Adăugăm apoi (sau în oricare moment) o definiţie de şir "MY_TEMPLATE", conţinând şablonul unui fişier HTML în care prevedem prin câte o pereche de acolade locurile în care să fie înlocuite cheile respective prin valorile prevăzute în dicţionarul indicat în construcţia lui 'c'.

(10*) Exerciţiu (default_urlconf() cu 'DEBUG=False')

Pagina P1 se obţine (conform mesajului specificat de cheia 'explanation' şi afişat în ultimul paragraf al lui P1) în două condiţii simultane: "you have DEBUG = True in your Django settings file" şi "you haven't configured any URLs". Dar putem obţine pagina P1 şi dacă "DEBUG=False" (încălcând prima condiţie), configurând corespunzător un URL.

Într-adevăr - am văzut mai sus că P1 este produsă de funcţia default_urlconf() din django/views/debug.py; nu avem decât să legăm un URL (de exemplu, http://py3.hom/get_P1) de această funcţie, adăugând în fişierul "chessg/urls.py":

from django.conf.urls import url
from django.contrib import admin
from django.views import debug  # adăugat acum
urlpatterns = [
    url(r'^get_P1$', debug.default_urlconf),  # adăugat acum
    url(r'^admin/', admin.site.urls),
]

Setând DEBUG=False în fişierul "settings.py" şi restartând Apache - URL-ul "vid" http://py3.hom/ produce acum mesajul "The requested URL / was not found on this server", iar http://py3.hom/get_P1 ne dă într-adevăr, pagina P1.

Iar dacă revenim în "settings.py" şi setăm DEBUG=True (după care restartăm Apache) - URL-ul "vid" va produce pagina P2 (v. mai sus), iar http://py3.hom/get_P1 ne va da iarăşi P1.

11. O pagină de încercare

Pentru a vedea cum merg lucrurile, să proiectăm deocamdată o simplă "pagină de încercare", angajând baza de date şi mecanismul de producere a paginii evidenţiat mai sus (pentru care avem de precizat unele îmbunătăţiri fireşti).

Să ne imaginăm o cerere (sau URL) http://py3.hom/first_try. În fişierul "chessg/urls.py" conectăm acest URL cu funcţia din games/views.py menită să-l deservească:

# chessg/urls.py
from django.conf.urls import url
from django.contrib import admin
from games import views
urlpatterns = [
    url(r'^first_try$', views.list_games),  # avem de formulat 'list_games()'
    url(r'^admin/', admin.site.urls),
]

Să definim acum funcţia list_games(), în "games/views.py", astfel:

# games/views.py
from django.shortcuts import render
from .models import Game
def list_games(request):
    list_games = Game.objects.all()
    context = {'games': list_games,
               'title': "My games - first try"} 
    return render(request, 'games.html', context)

Funcţia render() (aflată în django/shortcut.py) construieşte direct pagina de returnat (obiectul HttpResponse din schema formulată în §10), completând conform dicţionarului "context" locurile rezervate în acest scop din fişierul indicat drept "şablon HTML"; mai precis, locurile marcate prin '{{ title }}' şi respectiv '{{ games }}' găsite în fişierul "games.html", vor fi înlocuite cu 'My games - first try' şi respectiv, cu list_games (reprezentând obiectele 'Game' asociate înregistrărilor din tabelul "games_game" al bazei de date).

Fişierele considerate drept "şabloane HTML" trebuie să se afle în anumite directoare - denumite de obicei "templates/" - care trebuie specificate în "settings.py":

# în 'settings.py'
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],  # directorul şabloanelor *.html
        'APP_DIRS': True,
        # ...
    },
]

Să înfiinţăm acum directorul 'chessg/templates' şi să-i înscriem fişierul "games.html":

vb@Home:~/envPy3/chessg$ mkdir templates; cd templates
vb@Home:~/envPy3/chessg/templates$ touch games.html

Şi în sfârşit, să formulăm (minimal) "şablonul HTML" respectiv, templates/games.html:

<!DOCTYPE html>
<html><head>
  <title>{{ title }}</title>
</head>
<body>
  <p> <strong>{{ games.count }}</strong> games: </p>
  {% for game in games %}
    <p> {{ game }} </p>
  {% endfor %}
</body></html>

Restartăm apoi Apache şi lansăm http://py3.hom/first_try; redăm (ca sursă HTML, dar trunchiată) pagina generată:

django3.png

Se văd desigur, înlocuirile efectuate prin dicţionarul 'context' în funcţia list_games() (din fişierul games/views.py); de exemplu, {{ games.count }} a fost înlocuit cu 350 (având 350 de înregistrări, în tabelul 'games_game'). Şablonul iniţiat prin {% for game in games %} a generat 350 elemente <p>, fiecare conţinând reprezentarea textuală a câte unui obiect 'Game' (conform definiţiei __str__() din "models.py", pentru 'Game').

Constatăm însă că unele obiecte 'Game' sunt redate greşit; de exemplu pentru games[13]:

<p> vlad.bazon - (صلاح حسين (1-0 </p>

Rezultatul trebuia să apară la sfârşit (şi nu între nume) şi nu este '(0-1)' ci '(1-0)'; a intervenit aici un anumit conflict de direcţionalitate: scrierea arabă decurge de la dreapta spre stânga.
O corectură ad-hoc constă în constituirea de contexte direcţionale separate, ambalând numele în câte un element <span dir=auto> şi rezultatul într-un <span dir=ltr>:

django4.png
<p> {{ games.13 }} </p>  
<p> <span dir='auto'>{{games.13.white}}</span> - 
    <span dir='auto'>{{games.13.black}}</span> 
    (<span dir='ltr'>{{games.13.result}}</span>) </p> 

Browser-ul va analiza primul caracter: dacă este unul latin, va păstra direcţia "left-to-right", iar dacă este unul arab - va seta direcţia "right-to-left" (desigur, pentru games.13.result direcţia este totdeauna 'ltr', încât am fixat-o direct şi nu folosind 'auto').

Precizăm că în şabloane, indexarea într-o listă de obiecte ca şi accesarea proprietăţilor sau metodelor unui obiect, se indică prin "."; de exemplu, {{games.13.white}} selectează al 14-lea obiect (se indexează de la zero) din lista games furnizată de contextul asociat fişierului şablon respectiv şi accesează membrul white al acestuia.

12. Structura şi funcţionalitatea de principiu a site-ului

Toate etapele dezvoltării (începând cu modelarea datelor) trebuie să aibă în vedere această chestiune: ce pagini ai de oferit (şi cum le îmbini între ele, evitând ridicolul)? Şi totdeauna, ai de ales între mai multe posibilităţi, foarte diferite; poţi angaja numai resurse de pe server (Python, Django), sau poţi folosi şi resurse disponibile în browser (javaScript, jQuery).

Structura de site pe care am mizat în mod implicit (sau subconştient) până acum, are unele aspecte ridicole (sau, lipsite de bun-simţ), care trebuie acum regândite.

Pagina de bază listează partidele înregistrate în baza de date; am realizat aceasta şi în §11, dar acum avem în vedere să paginăm lista respectivă (folosind django.core.paginator) şi să montăm câte un "link" pe nume, pe codurile de ţară, pe rezultat şi pe lista primelor 4-5 mutări (… link pe fiecare nume şi cod - ridicol, fiindcă un acelaşi nume sau cod poate apărea de mai multe ori în pagină):

django5.png

Ziceam iniţial că valorile din coloana "idx" ar permite referirea ulterioară (de exemplu, într-o eventuală secţiune de "Comentarii") a uneia sau alteia dintre partide… N-ar fi rău să fie aşa, numai că este incorect (şi vom abandona această coloană): dacă se vor şterge unele partide (de către "superuser", sau de către un user autorizat), atunci valorile "idx" se modifică.

Link-ul asociat numelui jucătorului va produce o nouă pagină, conţinând partidele acestuia (existente în baza de date); link-ul de pe codul unei ţări va duce la o pagină conţinând partidele în care unul dintre parteneri ţine de acea ţară, iar link-ul din ultima coloană listează într-o nouă pagină, toate partidele care încep cu mutările indicate de acesta.

Link-ul din coloana desemnată mai sus prin "_Browse" (conţinând rezultatul partidei respective) va deschide o nouă pagină, în care intenţionăm să prezentăm grafic lista de mutări, printr-o interfaţă de vizualizare interactivă a poziţiilor curente (implicând PGN-browser).

Avem două posibilităţi - foarte diferite între ele - pentru a realiza aceste cinci pagini. Prima posibilitate: obţinem de la server pagina de bază (de exemplu prin http://py3.hom/); prin urmare, dispunem de lista tuturor partidelor aflate la momentul respectiv în baza de date - încât celelalte patru pagini pot fi realizate fără a mai cere ceva de la server (exceptând poate, cererea pentru pachetul javaScript necesar paginii "_Browse"), implicând doar funcţii javaScript care să filtreze lista de partide din pagina de bază (după jucător, după ţară, respectiv după deschidere).

Funcţiile javaScript necesare sunt chiar uşor de formulat: întâi demascăm toate liniile tabelului afişat în pagina de bază; apoi mascăm toate liniile care nu satisfac criteriul indicat (în jQuery avem ceva de genul show() / hide() pentru a arăta/ascunde un element HTML). Să observăm însă, că pentru a proceda astfel trebuie să dispunem de tabelul tuturor partidelor (ori dacă implicăm django.core.paginator, atunci avem numai tabelul partidelor din "pagina" curentă).

Această manieră de lucru presupune în mod implicit că baza de date nu se modifică în timp; ori este firesc să considerăm că din când în când se vor şterge sau se vor adăuga partide, astfel că este posibil ca pagina de bază obţinută acum vreo oră de la server (şi implicit, paginile derivate ulterior din aceasta, filtrând cu javaScript) să nu mai reflecte conţinutul curent al bazei de date (dar putem reîncărca din când în când pagina de bază, diminuând "defectul" menţionat).

Vom alege aici a doua posibilitate de lucru (cea obişnuită în Django): setăm câte un URL pentru fiecare pagină (inclusiv pentru "_Browse"!). Renunţăm desigur, la ideea iniţială de a monta câte un link pe fiecare nume de jucător şi pe fiecare cod de ţară: un acelaşi nume sau cod poate apărea de mai multe ori în lista de partide redată într-o pagină sau alta şi chiar ar fi ridicol să avem câte un (acelaşi) link pe fiecare dintre acestea.

Pentru a accesa URL-ul respectiv - vom avea iarăşi, două posibilităţi: folosim obişnuit Django, sau combinăm cu mecanismul AJAX asigurat de jQuery.

(12*) Actualizare: Django 2.0

Dar tocmai s-a lansat public Django 2.0 (care exclude Python 2.7); să actualizăm vechiul pachet 'django' (din mediul virtual ~/envPy3/):

vb@Home:~/envPy3$ source bin/activate
(envPy3) vb@Home:~/envPy3$ pip install -U Django
...  Found existing installation: Django 1.11.7
    Uninstalling Django-1.11.7:
      Successfully uninstalled Django-1.11.7
Successfully installed Django-2.0

Restartând Apache, am constatat că http://py3.hom/admin/ şi http://py3.hom/first_try (din §11) funcţionează ca şi mai înainte (poate, ceva mai repede); înfiinţând de probă un nou proiect, vedem deocamdată că urls.py foloseşte acum django.urls şi simplifică sintaxa rutării URL-urilor (dar este păstrat şi django.conf.urls, asigurând compatibilitatea cu versiunile anterioare).

(12**) Manager, pentru filtrarea partidelor

Indiferent de cum vom alege să definim paginile respective, avem nevoie să filtrăm partidele după numele de jucător sau de ţară, sau după mutările iniţiale; putem grupa filtrele necesare, surclasând clasa models.Manager (definind un "manager de tabel" propriu, pe lângă cel implicit objects prin care se vizează toate înregistrările din tabel):

# completare în fişierul 'games/models.py'
## ...
from django.db.models import Q  # pt. construcţii SQL cu "WHERE clause1 OR clause2"
class GameManager(models.Manager):
    def by_players(self, prefix):
        return super(GameManager, self).get_queryset().filter(
                     Q(white__name__istartswith = prefix) | 
                     Q(black__name__istartswith = prefix))
  # def by_land(self, code2): ...
class Game(models.Model):
  pgn = models.TextField()
  white = models.ForeignKey(Player, on_delete=models.CASCADE, related_name="whites")
  black = models.ForeignKey(Player, on_delete=models.CASCADE, related_name="blacks")
  result = models.CharField(max_length=8)
  objects = models.Manager()  # Game.objects.all() (managerul implicit)
  search = GameManager()
## ...

În clasa Game am adăugat search = GameManager(), iar în GameManager am definit deocamdată doar funcţia by_players(). Prin Game.search.by_players('wis') de exemplu, vom obţine toate acele partide în care numele unuia dintre jucători are prefixul indicat ("wis"). Analog, prin Game.search.by_land('RU') am obţine toate partidele asociate codului de ţară indicat ("RU"). Adăugând un nou manager, trebuie să explicităm întâi managerul 'objects'.

E bine să nu uităm să înregistrăm (în games/migrations) modificarea făcută în "models.py":

(envPy3) vb@Home:~/envPy3/chessg$ python manage.py makemigrations games
Migrations for 'games':
  games/migrations/0003_auto_20171211_1102.py
    - Change managers on game
(envPy3) vb@Home:~/envPy3/chessg$ python manage.py migrate games

Am procedat aşa - în loc să pretindem numele complet al unui jucător şi să obţinem lista partidelor acestuia - pentru următorul motiv: în general, cei care joacă şah pe diverse site-uri folosesc nume fictive (foarte rar, ar avea sens să reţii un asemenea "username"; de exemplu totuşi - eu am reţinut "SillyBoy", la care am câştigat o singură partidă din vreo zece); în plus, pe instantchess.com poţi juca (folosind chiar numele contului pe care l-ai înregistrat şi l-ai plătit) şi fără să te autentifici (fiindcă, să zicem, ai uitat parola şi eşti pe un calculator sau telefon nou) - adăugând însă nişte cifre numelui respectiv (deci un acelaşi jucător apare în 'Player' de mai multe ori, numele respective diferind însă numai pe ultimele câteva caractere, de obicei cifre).

Desigur, prefixul respectiv trebuie obţinut de la utilizator; avem cel puţin trei posibilităţi (foarte diferite) pentru aceasta: să-l cerem ca un "query-string", de exemplu http://py3.hom/?nume=wis; sau să furnizăm un formular (un obiect "form") în care utilizatorul să tasteze prefixul (acţionând apoi butonul obişnuit "Submit", pentru a trimite datele); sau în sfârşit, să angajăm un mecanism implicit oferit de Django: http://py3.hom/wis va furniza pagina conţinând partidele asociate numelor de jucător cu prefixul "wis" (sau oricare altul, indicat direct în finalul URL-ului), dacă folosim nu funcţii view obişnuite (cu care ar fi mai greu de modelat astfel lucrurile), ci angajăm unele clase generice din CBV ("class-based-views").

13. Realizarea site-ului folosind ListView

Următoarele două imagini "anticipează" ceea ce intenţionăm să definim mai jos:

django6.pngdjango7.png

La baza paginilor avem informaţii adăugate în şablonul HTML prin tagul {% debug %}; de exemplu, vedem aici că s-a folosit django.core.paginator (în scopul redării am limitat la 3 partide, conţinutul unei pagini; pagina redată în dreapta nu are link-urile "Next" şi "Prev", fiindcă există numai 3 partide asociate numelui "wis"). Mai vedem că s-au folosit obiecte GameListView şi respectiv, PlayerView (pe care le vom defini şi descrie mai jos).

Am prevăzut un "menu" foarte simplu; click pe "Games" listează prima pagină din setul tuturor partidelor (în mod normal, primele 10 partide; apoi, click pe "Next" va aduce următoarele 10 partide ş.a.m.d.); click pe "Player" listează partidele asociate numelor de jucător care încep cu "a" sau "A", iar click pe "Land" ar lista partidele asociate cu ţara de cod "RU".

Se poate deja sesiza că apare o problemă: cum distingem între http://py3.hom/win (filtrare după jucător) şi http://py3.hom/RU (filtrare după codul ţării şi nu după jucător)? Următoarea convenţie ar fi cea mai simplă soluţie: pentru căutare după jucător trebuie folosite litere mici, iar pentru căutare după ţară trebuie folosite majuscule (dar numai primele două contează); de observat că în funcţia by_players() (din "manager"-ul redat mai sus) am folosit __istartwith (şi nu __startwith - la care vom reveni însă, în §14), ceea ce asigură căutarea indiferent de cazul literei (mare sau mică); verificarea faptului că prefixul transmis ca parametru începe cu literă mică s-ar cuveni să fie făcută în cadrul view-ului care va folosi managerul respectiv.

Avem de specificat legătura dintre URL-urile indicate mai sus şi funcţiile view care le rezolvă şi trebuie să definim aceste funcţii, împreună cu şabloanele HTML ale paginilor.

Creem întâi fişierul games/urls.py (pe care îl vom "include" apoi în fişierul chessg/urls.py al proiectului) astfel:

# chessg/games/urls.py  (URL --- vievs la nivelul aplicaţiei)
from django.urls import path
from . import views
urlpatterns = [
    path('', views.list_games, name="list_games"),
    path('<player>/', views.player_games, name="player_games"),
]
# chessg/urls.py  (URL --- vievs la nivelul proiectului)
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('games.urls')) 
]

"Calea" http://py3.hom/ (specificată de path('', ...)) va activa funcţia list_games() (din "views.py"); aceasta va folosi "managerul" Game.objects pentru a obţine toate partidele din baza de date (transmiţând lista respectivă - dar paginată - către un anumit şablon HTML şi returnând după compilarea acestuia, pagina respectivă).

O "cale" ca http://py3.hom/wis/ corespunde specificaţiei path('<player>', ...) (egalând formal <player> cu "wis"); funcţia player_games() (din "views.py") va trebui să folosească Game.search .by_players("wis"), obţinând lista asociată prefixului "wis" al numelor jucătorilor.

Scrierea funcţiilor menţionate este chiar foarte simplă, dacă ne bazăm pe clasa generică ListView (a vedea django/views/generic/list.py, de unde am imitat specificaţiile noastre):

# games/views.py
from django.views.generic import ListView
from django.shortcuts import render
from .models import Iflag, Player, Game

class GameListView(ListView):  # surclasează 'ListView'
    model = Game
    template_name = "games.html"
    paginate_by = 10  # implică automat 'django.core.paginator'
    paginate_orphans = 3
    context_object_name = "object_list"  # implicit ar fi: "game_list"
    
list_games = GameListView.as_view()

class PlayerView(ListView):
    template_name = "games.html"
    paginate_by = 10
    paginate_orphans = 3
    context_object_name = "object_list"
    
    def get_queryset(self):
        sname = self.kwargs['player']  # TODO: literă iniţială MICĂ
        return Game.search.by_players(sname)

player_games = PlayerView.as_view()

Dacă nu explicitam 'context_object_name', atunci la execuţia funcţiilor respective s-ar fi creat listele 'game_list' (pentru prima funcţie) şi object_list (pentru player_games()), cu acelaşi conţinut (dependent de pagina redată) - cum am constatat folosind {% debug %} în şablonul HTML.

În definiţia get_queryset(self) am ţinut seama de faptul că funcţiile view induse de CBV (în cazul de faţă, PlayerView.as_view()) capturează automat în "self", argumentele din URL-ul asociat în "urls.py" (în cazul de faţă, cel denumit "player").

Ne-a rămas să specificăm un şablon HTML care să redea object_list. Am avea şi pentru aceasta, de ales între foarte multe variante (în funcţie, în principal, de cât de bine şi frumos vrem să arate paginile finale - se pot implica biblioteci specializate pentru CSS+javaScript, precum Bootstrap).
Dar aici preferăm pagini cât mai simple; avem însă în vedere - folosind tag-ul {% block %} - să creem posibilitatea de a putea moşteni şablonul "de bază" următor, în cadrul unor şabloane HTML ulterioare:

          {# chessg/templates/games.html #}
{% load static %}<!DOCTYPE html>
<html><head>
  <meta charset="utf-8" />
  <title>Partide de şah (PGN->R data.frame->PostgreSQL->Django, pgn_browser)</title>
  <link rel="stylesheet" href="{% static 'slight.css' %}">
</head><body>
<p class="trail">{% block trail %}
    <a href="{% url 'list_games' %}">Games</a> <small>p.1</small> | 
    <a href="{% url 'player_games' 'a' %}">Player</a> <small>/a</small> | 
    <a href="">Land</a> <small>/RU</small>
{% endblock trail %}</p>
{% block content %}
<table>
    {% for game in object_list %}
<tr><td><em>{{game.white}}</em></td> 
    <td>{{game.white.iflag.code}}</td>  
    <td><em>{{game.black}}</em></td> 
    <td>{{game.black.iflag.code}}</td>
    <td><a href="">{{game.result}}</a></td>
    <td><a href=""><span>{{game.pgn | truncatewords:12}}</span></td>
<tr>{% endfor %}
</table>
    {% include 'pagination.html' %}
{%endblock content %}
{% comment %}
<pre> {% filter force_escape %} {#{sql_queries}#} {% debug %} {% endfilter %} </pre>
{% endcomment %}
</body></html>

De exemplu, şablonul HTML care va fi de formulat mai târziu pentru redarea grafică a unei partide va putea să folosească {% extends games.html %} şi doar să redefinească {% block content %} (şi eventual, să adauge un item - "titlul" partidei, să zicem - în {% block trail %}).

Fişierul "static/slight.css" specificat în <head> se rezumă la stilarea sumară a elementelor din <table> şi nu-l mai redăm aici. Mai precizăm că {{game.pgn | truncatewords(12)}} trunchiază lista mutărilor din partida respectivă la primele 12 "cuvinte" - adică la primele 4 mutări (de regulă, o "mutare" este reprezentată prin 3 cuvinte: numărul mutării, mutarea albului şi cea a negrului).

Am definit "paginarea" într-un şablon separat, pe care l-am include direct (folosind ca mai sus, directiva {% include %}) în oricare şablon HTML care ar necesita paginare:

          {# chessg/templates/pagination.html #}
{% if is_paginated %}
<table class="pagination"><tr>
    {% if page_obj.has_previous %}
        <td><a href="?page={{page_obj.previous_page_number}}">Prev</a></td>
    {% else %}<td><a href="#">Prev</a></td>{% endif %}

    {% if page_obj.has_next %}
        <td><a href="?page={{page_obj.next_page_number}}">Next</a></td>
    {% else %}<td><a href="#">Next</a></td>{% endif %}
      
    <td>({{page_obj.paginator.num_pages}} p.)</td></tr>
</table>
{% endif %}

Am preferat să dispunem doar de link-urile "Next" şi "Prev" (evitând să listăm şi link-uri pentru numerele de pagină); acestea creează câte un query-string pe care-l adaugă la URL-ul din bara de adresă a browser-ului (iar django.core.paginator "ştie" să-l folosească pentru a "cere" funcţiei view cu care este asociat, să transmită următoarea sau precedenta "pagină").

După ce restartăm Apache, putem obţine deja paginile intenţionate; de exemplu:

django8.png

Pentru link-ul "Land" am avea de completat definiţia funcţiei by_land() din GameManager, de definit o clasă "LandView(ListView)" - practic, am copia conţinutul din 'PlayerView' şi am schimba "return Game.search.by_players(sname)" cu "return Game.search.by_land(sname)" - şi de specificat apoi, atributul href al link-ului respectiv (în "games.html").

Apoi, pentru a "definitiva" aplicaţia, ar rămâne să ne ocupăm şi de link-urile de pe ultimele două coloane, asociate rezultatului partidei şi listei mutărilor de deschidere - specificând URL-uri, funcţii view şi şabloane HTML corespunzătoare. Desigur, definitivarea implică şi o anumită revedere a lucrurilor - încât am interveni puţin în structura 'data.frame' obţinută în §2 din fişierul PGN iniţial, pentru a rezolva unitar (din start) problema numelor care diferă numai printr-o mică porţiune cifrică finală (nume care de fapt, reprezintă un acelaşi jucător - de exemplu în pagina aleasă mai sus, acesta este "Borat Sagdiyev").

14. Versiune cu TemplateView şi Form-uri

Dar nu ne grăbim să "definitivăm aplicaţia"… Soluţia de mai sus ilustrează uşurinţa cu care se pot dezvolta aplicaţii Web tipice, angajând clasa ListView. Am început totuşi să vedem nişte "defecte": prefixul de nume cerut de link-ul "Player" trebuie să înceapă cu literă mică, altfel se confundă cu o cerere pentru "Land"; apoi, pentru link-ul "Land" (pe care nu ne-am grăbit să-l "definitivăm") ar trebui tastat un cod de ţară - ori partidele înregistrate în baza de date acoperă puţine ţări, încât pentru multe încercări am obţine o pagină "goală".

Iar impunerea de a extinde URL-ul cu numele sau codul pentru care s-ar vrea lista partidelor din baza de date este totuşi, netipică: utilizatorul obişnuit "ştie" că pentru a transmite datele cerute trebuie să completeze un formular (nu bara de adresă).

Prin urmare, avem o nouă provocare: să realizăm site-ul folosind două formulare (obiecte Form) - unul pentru a obţine partidele unui jucător (sau ale jucătorilor ale căror nume au un acelaşi prefix indicat) şi unul care să permită selectarea unei ţări dintr-o listă a ţărilor specificate deja în partide şi a obţine astfel, lista partidelor asociate ţării indicate. Mai mult, vrem să folosim un singur URL - pentru pagina "iniţială" cu toate partidele, dar şi pentru paginile care vor fi returnate în urma completării formularelor (toate paginile trebuind să includă şi cele două formulare).

Mai întâi, să ne amintim că în §9 am înregistrat ţările în tabelul games_iflag (conform standardului ISO 3166 pentru codurile ţărilor, utilizat în serviciile poştale, în denumirile domeniilor Internet, etc.). Să adăugăm în clasa 'Iflag' un "manager" care să furnizeze numai ţările corespunzătoare jucătorilor existenţi în tabelul games_player:

##     modificare în 'games.models.py'
class IflagManager(models.Manager):
    def in_games(self):
        filtru = set([player.iflag_id for player in Player.objects.all()])
        return super(IflagManager, self).get_queryset().filter(code__in = filtru)

class Iflag(models.Model):
  name = models.CharField(max_length=64)
  code = models.CharField(max_length=2, primary_key=True)
  objects = models.Manager()
  lands = IflagManager()

În funcţia in_games(), am selectat toate valorile câmpului 'iflag_id' din tabelul games_player, am eliminat duplicatele (folosind funcţia Python set()) obţinând "filtru" şi am returnat lista obiectelor 'Iflag' pentru care valoarea din câmpul 'code' aparţine mulţimii "filtru". Putem verifica direct funcţionalitatea respectivă (şi apoi, înregistrăm modificarea în games/migrations):

(envPy3) vb@Home:~/envPy3/chessg$ python manage.py shell
>>> from games.models import Iflag, Player, Game
>>> print( Iflag.lands.in_games().count() )
66  # ţările jucătorilor înregistraţi prin 'Player'
>>> print( Iflag.objects.all().count() )
254  # toate ţările, conform standardului ISO 3166
(envPy3) vb@Home:~/envPy3/chessg$ python manage.py makemigrations games
(envPy3) vb@Home:~/envPy3/chessg$ python manage.py migrate games

Să definim acum cele două formulare (înfiinţând fişierul games/forms.py):

# games/forms.py
from django import forms
from .models import Iflag

class PlayerForm(forms.Form):
    name = forms.CharField(required=False, empty_value="wis", max_length=10)
    prefix = 'player'
    
class LandForm(forms.Form):
    land = forms.ModelChoiceField(queryset = Iflag.lands.in_games())
    prefix = 'land'

Într-un şablon HTML, un obiect 'PlayerForm' va fi reprezentat printr-un element HTML <input> (în care utilizatorul va putea tasta primele maximum 10 caractere din numele unui jucător), iar 'LandForm' printr-un <select> având ca opţiuni ţările respective (dintre care se va putea alege una):

>>> from games.forms import PlayerForm, LandForm
>>> f, g = PlayerForm(), LandForm()
>>> print(f.as_p(), g.as_p()) 
<p><label for="id_name">Name:</label> 
    <input type="text" name="name" maxlength="10" id="id_name" /></p>
<p><label for="id_land">Land:</label>
<select name="land" id="id_land" required>
    <option value="" selected>---------</option> <option value="??">??</option>
    <option value="AF">Afghanistan</option>  # ETC. ... 
    <option value="ZM">Zambia</option>
</select></p>

Specificaţia 'prefix' ne va permite să disociem - în cadrul funcţiei view care va instanţia şi exploata cele două formulare - între datele trimise dintr-un formular şi respectiv, din celălalt. Fiindcă avem formulare foarte simple (au câte un singur câmp şi nu creează sau modifică înregistrări în baza de date), preferăm să le şablonăm direct (mai adecvat cazului nostru, decât şabloanele standard furnizate implicit prin as_p(), as_table() etc.):

{# de inclus în 'games.html', înlocuind elementul "<p class='trail'>" #}
<div>
<form method="post" action="" style="float:left;padding-right:1em;">{%csrf_token%}
    {{fplayer.name}} 
    <button type="submit" name="{{fplayer.prefix}}">Player</button>
</form>
<form method="post" action="" style="float:left;padding-left:1em">{%csrf_token%}
    {{fland.land}} 
    <button type="submit" name="{{fland.prefix}}">Land</button>
</form> <div style="clear:left"></div>
</div>

Am omis elementele <label>; iar acum, cele două formulare vor fi alăturate pe un acelaşi rând (fiind stilate cu "float: left"). Vom păstra fişierul şablon "games.html" din §13, înlocuind însă paragraful dinaintea blocului "{% block content %}" cu fragmentul HTML tocmai redat mai sus.

Precizăm că prin {% csrf_token %} se adaugă un element ascuns <input type='hydden' ... />, a cărui valoare este un anumit cuvânt dependent de un anumit "secret" şi de sesiunea de lucru curentă; astfel, funcţiile view se asigură că datele primite au ca sursă un formular al aplicaţiei (dintr-o sesiune de lucru a unui utilizator al acesteia) şi nu vreunul străin.

Pentru ţara selectată, va trebui să determinăm partidele asociate acesteia; putem proceda fără a face alte adăugiri în 'models.py': determinăm jucătorii 'Player' din ţara indicată, obţinem partidele fiecăruia dintre aceştia (folosind "scurtăturile" de relaţionare inversă 'whites' şi 'blacks' definite în clasa 'Game' - v. §8), rămânând apoi să returnăm reuniunea tuturor acestora:

>>> ru = Player.objects.filter(iflag_id="RU")
>>> ru[0].whites.first()
<Game: андрей чер - vlad.bazon (1-0)>
>>> ru[1].blacks.first()
<Game: vlad.bazon - Рафаел Айрапетян (1-0)>

Dar este mult mai simplu să operăm direct cu 'Game', făcând însă o nouă adăugire în 'models.py':

# adăugire în 'models.py' (funcţia by_land(), în GameManager)
class GameManager(models.Manager):
    def by_players(self, prefix):
        # ...
    def by_land(self, land):
        return super(GameManager, self).get_queryset().filter(
                     Q(white__iflag_id = land) | Q(black__iflag_id = land))

Dacă 'Russia' este ţara selectată, atunci Game.search.by_land('RU') ne va da lista tuturor partidelor asociate acesteia, din tabelul games_game.

Funcţia view pe care o avem de formulat, va trebui să genereze contextul necesar compilării şablonului "games.html" (şi să returneze rezultatul): în cazul unei cereri prin HTTP GET (cum ar fi http://py3.hom/, lansată iniţial din browser, sau lansată după primirea primei pagini dar fără a folosi formularele oferite), contextul respectiv va trebui să conţină lista tuturor obiectelor 'Game', plus cele două formulare (în starea primară, sau "goale"); în cazul unei cereri prin HTTP POST (când utilizatorul completează unul sau altul dintre formulare şi foloseşte butonul "submit", încât browser-ul transmite şi datele respective), contextul respectiv va trebui să conţină lista obiectelor Game specificată prin formularul care fusese acţionat, plus iarăşi, cele două formulare "goale".

Toate clasele generice (în §13 am folosit ListView) se bazează pe (sau "moştenesc") clasa 'View'; aceasta prevede metoda as_view() - prin care clasa respectivă se va putea folosi drept "funcţie view" asociată în "urls.py" unui anumit URL - iar aceasta delegă rezolvarea cererii către o metodă get() (pentru cereri prin HTTP GET), sau către o metodă post() (pentru HTTP POST). În funcţia view pe care o avem de formulat, avem de definit corespunzător funcţiile get() şi post().

Să renunţăm deocamdată la paginarea rezultatelor: django.core.paginator (folosit în §13, prin ListView) mizează pe "query string" (deci pe HTTP GET, cum şi aveam în §13) pentru a reda o pagină de rezultate sau alta - ori acum avem de-a face şi cu cereri HTTP POST şi pare complicat să paginăm şi rezultatele acestora (deci eliminăm, sau comentăm {#% include 'pagination.html' %#} din finalul şablonului "games.html").

Prin urmare, putem angaja o clasă mai simplă decât ListView - conţinând doar ceea ce am evidenţiat ca necesar mai sus - anume, TemplateView; aceasta permite generarea "manuală" a unui context sau altul şi asigură compilarea unui şablon precizat (folosind contextul respectiv) şi returnarea rezultatului:

# games/views.py
from django.views.generic import TemplateView
from .models import Iflag, Player, Game
from .forms import PlayerForm, LandForm

class GameView(TemplateView):
    template_name = "games.html"

    def _render(self, object_list):
        return self.render_to_response({
                        'object_list': object_list,
                        'fplayer': PlayerForm(), 
                        'fland': LandForm() })

    def get(self, request, *args, **kwargs):  # pentru HTTP GET
        return self._render(Game.objects.all())

    def post(self, request, *args, **kwargs):  # pentru HTTP POST
        if 'player' in request.POST:
            fplayer = PlayerForm(request.POST)
            if fplayer.is_valid():
                sname = fplayer.cleaned_data["name"]
                return self._render(Game.search.by_players(sname))
        if 'land' in request.POST:
            fland = LandForm(request.POST)
            if fland.is_valid():
                land = fland.cleaned_data["land"]
                return self._render(Game.search.by_land(land))
        # return self.render_to_response({'player': PlayerForm(), 'land': LandForm()})
        
list_games = GameView.as_view()    

Metoda _render() constituie un dicţionar Python conţinând lista de obiecte - primită fie de la get(), fie din post() - şi cele două formulare "goale" şi returnează rezultatul compilării cu acest context a şablonului "games.html". Ultima linie (comentată) din post() ar fi necesară numai în cazul când unul dintre formulare ar fi invalidat (is_valid() ar returna 'False', din cauză că datele transmise nu ar putea fi acceptate) - ceea ce nu este cazul aici: datele transmise nu sunt folosite pentru a face modificări în baza de date şi ele nici măcar nu sunt reafişate în pagina returnată (deci se pot accepta orice fel de date; Game.search.by_... va returna o listă vidă pentru toate cazurile în care datele respective nu au asociate înregistrări corespunzătoare, în baza de date).

În următorul "screencast" se poate vedea cum arată şi cum funcţionează site-ul realizat astfel:

Am "pozat" pe lângă paginile site-ului şi informaţii furnizate de instrumentul de dezvoltare oferit de browser - în principal, parametrii cererilor HTTP POST (token-ul ascuns adăugat în fiecare formular de către Django, "prefixul" specific formularului şi datele încorporate), extraşi apoi de către funcţiile view din dicţionarul intern request.POST.

Paginarea rezultatelor ar putea fi lăsată în seama vreunui plugin javaScript (aş pomeni modestul Plugin jQuery pentru paginarea unui tabel HTML). Dar avem de observat că "paginarea" în browser este doar un surogat (se aplică după ce s-au primit toate rezultatele); ar fi de paginat nu rândurile derulate în fereastra browserului, ci obţinerea efectivă şi transmisia rezultatelor: paginator-ul din Django extrage efectiv doar înregistrările pentru pagina curentă, lăsând utilizatorul să decidă dacă mai vrea şi o altă tranşă.

15. Filtrarea după deschidere (mutările iniţiale)

Versiunea din §14 nu paginează rezultatele, dar acest neajuns este compensat de simplitatea şi concizia codului aferent şi de flexibilitatea asigurată (fiind uşor de extins pentru noi formulare). Avem de îmbunăţăţit unele aspecte: ar fi de dorit ca itemul selectat prin butonul "Land" să devină opţiunea curentă a listei respective, în viitoarea pagină a rezultatelor cererii curente (în loc să avem ca acum, <option value="" selected>---------</option>, să avem <option value="RU" selected>Russia </option> dacă "Russia" fusese aleasă iniţial); analog, faţă de "Player".

Deasemenea, este de corectat modul în care am prezentat mutările iniţiale (în ultima coloană din tabelul partidelor): este ridicol să avem link-uri, fiindcă multe partide încep cu aceleaşi mutări şi link-urile asociate acestora ar da o aceeaşi pagină de rezultate. Vom proceda analog cazului "LandForm", definind un formular care să conţină lista deschiderilor (primele 4 mutări) întâlnite în înregistrările din tabelul "games_game" (pe câmpul "pgn") şi vom completa funcţia post() din clasa 'GameView' astfel încât pentru itemul selectat din această listă să se constituie şi să se înscrie în "games.html" lista partidelor corespunzătoare deschiderii respective (în a 4-a coloană având menţionate două-trei mutări iniţiale).

Procedând cum vom descrie mai jos, vom ajunge la funcţionalitatea redată prin următorul "screencast" (în care se pot sesiza modificările tocmai vizate):

În 'GameManager' adăugăm o metodă by_opening(), pentru filtrarea partidelor după o secvenţă dată, de mutări iniţiale:

# adăugire în "games/models.py" (în 'GameManager')
class GameManager(models.Manager):
    def by_players(self, prefix):
        # ...    
    def by_land(self, land):
        # ...
    def by_opening(self, first_moves):
        return super(GameManager, self).get_queryset().filter(
                     pgn__startswith = first_moves)

De exemplu, găsim lista partidelor care încep cu "1.e4 Nf6":

vb@Home:~/envPy3/chessg$ source ../bin/activate
(envPy3) vb@Home:~/envPy3/chessg$ python manage.py shell
>>> from games.models import *
>>> Game.search.by_opening('1. e4 Nf6')
<QuerySet [<Game: Modesto Garcia - vlad.bazon (0-1)>, <Game: thorwald - vlad.bazon (0-1)>,
           <Game: chessnick376 - vlad.bazon (0-1)>, <Game: alvaro562 - vlad.bazon (1-0)>,
           <Game: jakovlev.s - vlad.bazon (0-1)>]>

Definim acum un formular "OpeningForm", similar cu "LandForm":

# adăugire în "games.forms.py"
import re
def _openings():
    first4 = sorted(list( set([re.match(r'(1\..*) 5\.', game.pgn).group(1) 
                          for game in Game.objects.all()]) ))
    return [(first4[i], first4[i]) for i in range(len(first4))]
class OpeningForm(forms.Form):
    opening = forms.ChoiceField(choices = _openings())
    prefix = 'opening'

În funcţia _openings() se extrag primele 4 mutări din câmpurile "pgn" ale tuturor obiectelor 'Game', se elimină duplicatele şi se ordonează lista obţinută; se returnează o listă formată din perechi în care componentele sunt respectiv, identice - reprezentând cele 4 mutări. Lista de perechi returnată va deveni valoarea câmpului 'opening' (de tip ChoiceField) al formularului; prima valoare din fiecare pereche a listei va servi pentru setarea atributului "value", iar a doua - pentru înscrierea conţinutului elementelor <option> subiacente elementului <select> asociat formularului în şablonul HTML "games.html":

>>> from games.forms import _openings
>>> print(_openings()[:2])  # sublista primelor două perechi
[('1. Nf3 c5 2. c4 Nf6 3. g3 d5 4. cxd5 Nxd5', '1. Nf3 c5 2. c4 Nf6 3. g3 d5 4. cxd5 Nxd5'), 
('1. Nf3 c5 2. c4 g6 3. Nc3 Bg7 4. g3 b6', '1. Nf3 c5 2. c4 g6 3. Nc3 Bg7 4. g3 b6')]
>>> f = OpeningForm()
>>> print(f["opening"][0]) # reprezentarea HTML a primei perechi
<option value="1. Nf3 c5 2. c4 Nf6 3. g3 d5 4. cxd5 Nxd5">
        1. Nf3 c5 2. c4 Nf6 3. g3 d5 4. cxd5 Nxd5</option>

Avem de rescris funcţia _render() (şi implicit, de modificat apelurile acesteia) şi de adăugat o ramură (pentru noul formular) în metoda post(), în cadrul clasei 'GameView' din "views.py":

#           games/views.py
from django.views.generic import TemplateView
from .models import Iflag, Player, Game
from .forms import PlayerForm, LandForm, OpeningForm

class GameView(TemplateView):
    template_name = "games.html"

    def _render(self, object_list, vplayer="", vland="", vopening=""):
        truncw = 15 if(vopening) else 8
        return self.render_to_response({ 'object_list': object_list, 
               'truncw': truncw, 'vplayer': vplayer, 'vland': vland, 
               'vopening': vopening, 'fplayer': PlayerForm(), 
               'fland': LandForm(), 'fopening': OpeningForm() })

    def get(self, request, *args, **kwargs):
        return self._render(Game.objects.all())

    def post(self, request, *args, **kwargs):
        if 'player' in request.POST:
            fplayer = PlayerForm(request.POST)
            if fplayer.is_valid():
                sname = fplayer.cleaned_data["name"]
                return self._render(Game.search.by_players(sname), vplayer=sname)
        elif 'land' in request.POST:
            fland = LandForm(request.POST)
            if fland.is_valid():
                land = fland.cleaned_data["land"]
                return self._render(Game.search.by_land(land), vland=land)
        elif 'opening' in request.POST:
            fopening = OpeningForm(request.POST)
            if fopening.is_valid():
                firstm = fopening.cleaned_data["opening"]
                return self._render(Game.search.by_opening(firstm), vopening=firstm)
        
list_games = GameView.as_view()    

În metoda _render() am prevăzut câteva elemente noi, faţă de versiunea din §14; 'truncw' va permite ca în şablonul "games.html" să folosim {{game.pgn | truncatewords:truncw}}, încât pentru partidele asociate formularului 'OpeningForm' se vor afişa primele 5 mutări (contextul în acest caz va conţine truncw: 15), iar pentru celelelte cazuri (pentru care vom avea truncw: 8) se vor afişa numai câte 2.5 mutări iniţiale; 'vplayer', 'vland' şi 'vopening' vor fi nule în cazul când _render() va fi apelată din get(), iar în cazul apelării din post() una dintre acestea va fi setată corespunzător formularului prin care s-au cerut rezultatele transmise (astfel, în şablonul "games.html" se va putea marca - folosind javaScript - formularul din care provin partidele afişate).

În şablonul "games.html", întâi adăugăm elementul <form> pentru formularul 'OpeningForm' şi modificăm conţinutul celui de-al 6-lea element <td> (eliminând "link"-ul care fusese prevăzut iniţial şi folosind acum {{game.pgn | truncatewords: truncw}}):

{# în "games.html" (adăugăm noul formular; modificăm ultimul <td>) #}
<div>
{# ... (vechile formulare) #}
<form method="post" action="" style="float:left;padding-left:2em">{%csrf_token%}
    {{ fopening.opening }} 
    <button type="submit" name="{{fopening.prefix}}">Opening</button>
</form>
<div style="clear:left"></div></div>

{% block content %}
<div style="height:500px;overflow:auto">
    <table>
        {% for game in object_list %}
        <tr>
            {# ... (vechile 5 coloane) #}
            <td><span>{{game.pgn | truncatewords:truncw}}</span></td>
        <tr>{% endfor %}
    </table>
</div>
{%endblock content %}

De observat că am mai făcut o modificare: am ambalat tabelul rezultatelor (care ar putea să fie şi foarte lung) într-un element <div> căruia i-am fixat o înălţime rezonabilă şi i-am prevăzut stilul "overflow: auto" - compensând oarecum faptul că rezultatele nu sunt paginate (utilizatorul putând să scroll-eze în diviziunea respectivă, pentru a vedea restul rândurilor afişate curent).

În sfârşit, în "games.html" adăugăm (printr-un element <script> care să fie executat imediat după încărcarea paginii în fereastra browserului) o secvenţă javaScript prin care - în cazul când rezultatele redate au fost produse de metoda post() (şi nu de get()) - să se actualizeze atributul 'value' (sau proprietatea 'selected') din elementul specific formularului căruia i-a răspuns post(), cu valoarea setată sau aleasă de către utilizator când a trimis formularul respectiv:

{# de adăugat înainte de </body>, în "games.html" #}
<script>
    document.getElementById('id_player-name').value = "{{vplayer}}";
    var sel = "";
    if("{{vland}}") {
        sel = document.querySelector('select[name="land-land"]');
        for(var i=0; i < sel.length; i++)
            if(sel[i].text=="{{vland}}") break;
        sel[i].selected = true;
    }
    if("{{vopening}}") {
        sel = document.querySelector('select[name="opening-opening"]');
        for(var i=0; i < sel.length; i++)
            if(sel[i].value=="{{vopening}}") break;
        sel[i].selected = true;
    }
</script>
</body></html>

Desigur, script-ul redat mai sus putea fi redus la numai trei linii, dacă angajam jQuery - ceea ce evităm deocamdată: jQuery a ajuns la versiunea 3 (şi se cuvine mai mereu, să folosim ultima versiune), dar django.contrib.admin foloseşte versiunea 2.2, iar "pgn-browser"-ul pe care urmează să-l angajăm pentru a reda grafic o partidă de şah, este bazat pe jQuery 1.11 (aşa că ar rămâne de văzut ce versiune de jQuery ar fi mai potrivit de folosit).

16. Amănunte şi mici reparaţii

Pentru numele care conţin "caractere speciale", testul if(sel[i].text=="{{vland}}") din <script>-ul redat mai sus, va eşua: 'vland' vine din Python (prin apelarea metodei post(), din clasa 'GameView'), deci după compilarea şablonului HTML are de exemplu, valoarea "Bosnia & Herzegovina"; dar 'sel[i].text' vine din pagina HTML (textul unuia dintre elementele <option> al listei de selecţie din formularul "Land"), având valoarea "Bosnia &amp; Herzegovina" (regula de bază fiind aceea că pentru încorporarea într-o pagină HTML, caracterele speciale sunt transformate în "entităţi HTML"; de exemplu, '<' trebuie scris "&lt;" pentru a nu confunda cu scrierea specifică pentru "tag-uri HTML").

"Reparaţia" ar trebui să fie făcută cât mai devreme, dar ne putem mulţumi şi să înlocuim (în final) testul menţionat - prin 'if(sel[i].text.replace(/&/, "&amp;") == "{{vland}}") break;'.

Un alt aspect care trebuie cumva îndreptat, ţine de particularitatea că fişierul PGN iniţial conţine partide proprii (ceea ce desigur, nu este obligatoriu); ca urmare, selectând 'Romania' (din lista de opţiuni asociată lui "Land") - se va obţine lista tuturor partidelor înregistrate (fiindcă în funcţia by_land() din clasa 'GameManager' din "models.py", am prevăzut includerea partidei în lista de returnat, imediat ce măcar unul dintre parteneri este asociat ţării indicate).

O idee firească pentru "reparaţie" ar fi aceasta: dacă este indicată "Romania" - sau, mai general, una dintre ţările înscrise în prealabil într-o anumită listă - atunci să se includă în lista rezultatelor partidele în care ambii jucători, sunt asociaţi ţării respective.
Iarăşi, reparaţia trebuie făcută cât mai devreme; putem înscrie lista respectivă de ţări "speciale", în fişierul de configurare a proiectului chessg/settings.py, dar şi mai bine este să creem un nou fişier games/_settings.py, pentru configurări specifice aplicaţiei:

          games/_settings.py
from django.conf import settings
SPECIAL_LANDS = getattr(settings, "SPECIAL_LANDS", ["RO", ])

getattr() caută în chessg/settings.py cheia indicată şi returnează valoarea ei dacă există, altfel returnează lista precizată în al treilea argument (lista codurilor de ţară pentru care prevedem "reparaţia" menţionată).

În clasa 'GameManager' din "models.py" includem lista specificată în "_settings.py" şi ramificăm 'by_land()' pentru cazul când parametrul 'land' corespunde unei valori din această listă:

#  modificare 'by_land()' în "GameManager" (models.py)
from django.db.models import Q
from ._settings import SPECIAL_LANDS
class GameManager(models.Manager):
    def by_players(self, prefix):
        # ...
    def by_land(self, land):  # ce este 'land'? un nume, ca 'RO'? un obiect?
        if land.code in SPECIAL_LANDS:
            ret = super(GameManager, self).get_queryset().filter(
                        white__iflag_id = land, black__iflag_id = land)
        else:
            ret = super(GameManager, self).get_queryset().filter(
                        Q(white__iflag_id = land) | Q(black__iflag_id = land))
        return ret
    def by_opening(self, first_moves):
        # ...

Subliniem că putem apela direct Game.search.by_land('RO') (indicând codul ţării), dat fiind că pentru obiectele 'Iflag' avem drept "cheie primară" chiar câmpul 'code'; dar mai sus, în by_land(), ar fi fost greşită formularea "if land in SPECIAL_LANDS" (în loc de "if land.code") - fiindcă by_land() va fi apelată din metoda post() (a clasei 'GameView' din "views.py") astfel:

# secvenţă din metoda 'post()' a clasei 'GameView'  ("views.py")
        elif 'land' in request.POST:
            fland = LandForm(request.POST)
            if fland.is_valid():
                land = fland.cleaned_data["land"]
                return self._render(Game.search.by_land(land), vland=land)

Secvenţa tocmai evocată spune clar că în apelul by_land(land), parametrul 'land' este o instanţă (deja validată) a clasei "LandForm" - deci reprezintă un obiect 'Iflag' (dacă ne amintim în plus, că 'LandForm()' are un câmp de tip "ModelChoiceField" în care sunt selectate obiecte 'Iflag'); prin urmare, trebuie folosit "land.code" (şi nu "if land"), în definiţia redată mai sus a lui by_land().

Prin modificările descrise mai sus obţinem acum (selectând 'Romania' şi folosind butonul 'Land') numai partidele în care ambii jucători sunt asociaţi cu 'RO' (şi nu toate partidele, ca înainte):

django91.png

Pe imaginea redată se poate observa încă o "reparaţie" (anticipând următoarele două secţiuni): am înlocuit link-urile din coloana rezultatelor cu butoane (de fapt, cu formulare "ascunse" bazate tot pe metoda HTTP POST, care ar permite obţinerea unei pagini care să redea desfăşurarea uneia sau alteia dintre partidele afişate în pagina curentă).

17. Integrarea paginii specifice unei partide

Vrem să modelăm acum, următoarea funcţionalitate: la acţionarea butonului inscripţionat cu rezultatul partidei, să se obţină pagina specifică acelei partide; dar această nouă pagină să fie bazată pe acelaşi şablon HTML care a generat pagina de bază - deci să conţină iarăşi, cele trei formulare, iar acestea să fie actualizate pe valorile specifice partidei respective.

De exemplu, pentru înregistrarea:

django93.png

acţionând butonul marcat - să obţinem (deocamdată) pagina:

django92.png

'Player' a fost actualizat cu numele partenerului meu - încât, acţionând butonul 'Player' se vor reda toate partidele lui 'Vovan_0407'; 'Land' s-a actualizat cu numele ţării asociate partenerului meu - încât acţionând butonul 'Land' se vor obţine toate partidele asociate cu 'Belarus'; iar 'Opening' s-a actualizat cu mutările iniţiale din partida respectivă - încât, acţionând butonul 'Opening' se vor lista partidele corespunzătoare acestei deschideri.

Amânăm pentru §18, specificarea conţinutului pentru "pagina specifică" a partidei; deocamdată, am inclus "titlul" partidei şi un element <textarea> care conţine lista mutărilor. Folosim tot şablonul "games.html" considerat până acum; dar anticipăm un termen 'pgn_brw', pentru a ramifica {% block content %} după cum este vorba de "pagina specifică" unei partide, respectiv de celelalte pagini (asociate anterior celor trei formulare):

{#  modificare în "games.html"  #}
{% block content %}
{% if pgn_brw %}  {# pagina specifică partidei #}
    <p>{{object_list.white}} {{object_list.black}} {{object_list.result}}</p>
    <textarea readonly="readonly">{{object_list.pgn}}</textarea>

{% else %}  {# paginile care listează partide (înregistrări) #}
    <div style="height:500px;overflow:auto"><table>
        {% for game in object_list %}
    <tr><td><em>{{game.white}}</em></td><td>{{game.white.iflag.code}}</td>  
        <td><em>{{game.black}}</em></td><td>{{game.black.iflag.code}}</td>
{#  înlocuim link-ul vechi <td><a href="">{{game.result}}</a></td> cu un formular:  #}
        <td><form method="post" action="">{%csrf_token%}
                <input type="hidden" name="gamek" value="{{game.pk}}">
                <button type="submit" name="pgn_brw">{{game.result}}</button>
            </form></td>
        <td><span>{{game.pgn | truncatewords:truncw}}</span></td>
    <tr>{% endfor %}
    </table></div>
{% endif %}
{%endblock content %}

Mai înainte prezentam game.result într-un link (implicând metoda HTTP GET); acum l-am montat într-un <form> prin care se va putea trimite (cu HTTP POST) valoarea cheii primare game.pk, conţinută în <input>-ul "ascuns" denumit 'gamek'. Am păstrat action="" (ca şi în formularele considerate anterior), încât funcţia care va primi şi va exploata 'gamek' este views.list_games (prevăzută deja în "urls.py" pentru calea "", adică pentru http://py3.hom/); completăm pentru cazul acestui nou formular, definiţia metodei post() din clasa 'GameView':

# completare în metoda post() din 'GameView' ("views.py")
    def post(self, request, *args, **kwargs):
        if 'player' in request.POST:
            # ... (nemodificat)
        elif 'land' in request.POST:
            #... (nemodificat)
        elif 'opening' in request.POST:
            #... (nemodificat)
# adăugăm ramura 'pgn_brw' (produce "pagina specifică" partidei)
        g = Game.objects.get(pk = request.POST.get('gamek'))
        land1, land2 = g.white.iflag, g.black.iflag  # obiecte 'Iflag'
        land = land1 if ((land1 != land2) and 
                         (land1.code not in SPECIAL_LANDS)) else land2
        sname1, sname2 = g.white.name, g.black.name  # numele jucătorilor
        sname = sname1[:10] if sname1 not in SPECIAL_LANDS else sname2[:10]
        first4 = re.match(r'(1\..*) 5\.', g.pgn).group(1)  # de adăugat 'import re'
        return self._render(g, pgn_brw='pgn_brw', 
                            vland=land, vopening=first4, vplayer=sname)
        
list_games = GameView.as_view()    

În secvenţa adăugată, se determină obiectul 'Game' corespunzător valorii de cheie primară extrase din dicţionarul request.POST (pentru cheia "gamek", asociată <input>-ului denumit astfel, al formularului din care s-a postat cererea HTTP); apoi, se determină valorile necesare pentru actualizarea formularelor din şablonul HTML: ce ţară 'land' trebuie înscrisă în formularul 'Land', ce nume (sau prefix) 'sname 'trebuie înscris în 'Player' (şi a trebuit să ţinem seama de faptul că în 'PlayerForm' definisem numele ca având maximum 10 caractere) şi ce deschidere 'first4' trebuie setată ca fiind valoarea curentă în formularul 'Opening'.

Să observăm încă o dată (v. şi §16), că în 'Land' trebuie transmis un obiect 'Iflag' (fiindcă definiţia 'LandForm' angajează perechi (name_ţară, code_ţară)), iar în 'Player' - doar un şir, de maximum 10 caractere (de aceea a trebuit 'land1.code not in SPECIAL_...').

Pentru a actualiza 'Player' a trebuit să alegem: numele căruia dintre cei doi parteneri, trebuie înscris ca valoare curentă? Fiindcă am considerat o colecţie de partide proprii, trebuie înscris nu numele meu, ci numele partenerului meu; aşa că am adăugat o nouă configurare iniţială în "games/_settings.py" - dar în cel mai simplu mod: am adăugat 'vlad.bazon' în lista instituită deja SPECIAL_LANDS (încât mai sus, 'sname' va prelua numele care "nu este special").

Secvenţa adăugată metodei post() şi descrisă mai sus, impune câteva mici adăugiri în "views.py": trebuie importat modulul Python 're' (fiindcă am folosit re.match(), când am determinat mutările iniţiale 'first4'); trebuie importată lista 'SPECIAL_LANDS' - de exemplu, adăugând-o în "from .models import ..." (fiindcă în "models.py" avem deja 'from ._settings import SPECIAL_LANDS'). Apoi, în lista de argumente ale metodei _render(), trebuie adăugat 'pgn_brw = ""', iar în dicţionarul cu care această metodă apelează render_to_response() trebuie adăugat ", pgn_brw : pgn_brw".

Deci acum, avem:

    def _render(self, object_list, vplayer="", vland="", vopening="", pgn_brw=""):
        truncw = 15 if(vopening) else 8
        return self.render_to_response({ 'object_list': object_list, 
                pgn_brw: pgn_brw, 'vplayer': vplayer, 'vland': vland, 
                'vopening': vopening, 'truncw': truncw, 'fplayer': PlayerForm(), 
                'fland': LandForm(), 'fopening': OpeningForm() })

Dar această formulare este foarte urâtă! În fond, argumentele 'vplayer', 'vland', 'vopening', 'truncw' şi 'pgn_brw' sunt setate la apelarea metodei _render() - încât putem evita explicitarea acestora, adoptând o formulare mai "python-istă": _render(self, object_list, **kwargs), în care dicţionarul 'kwargs' urmează să fie actualizat de către funcţia care va apela _render():

#          games/views.py (definitivare)
from django.views.generic import TemplateView
from .models import Iflag, Player, Game, SPECIAL_LANDS
from .forms import PlayerForm, LandForm, OpeningForm
import re

class GameView(TemplateView):
    template_name = "games.html"
    
    def _render(self, object_list, **kwargs):
        context = {'object_list': object_list, 'fplayer': PlayerForm(),
                   'fland': LandForm(), 'fopening': OpeningForm(), **kwargs}
        return self.render_to_response(context)

    def get(self, request, *args, **kwargs):
        return self._render(Game.objects.all(), truncw=8)

    def post(self, request, *args, **kwargs):
        if 'player' in request.POST:
            fplayer = PlayerForm(request.POST)
            if fplayer.is_valid():
                sname = fplayer.cleaned_data["name"]
                return self._render(Game.search.by_players(sname), 
                                    vplayer=sname, truncw=8)
        elif 'land' in request.POST:
            fland = LandForm(request.POST)
            if fland.is_valid():
                land = fland.cleaned_data["land"]
                return self._render(Game.search.by_land(land), 
                                    vland=land, truncw=8)
        elif 'opening' in request.POST:
            fopening = OpeningForm(request.POST)
            if fopening.is_valid():
                firstm = fopening.cleaned_data["opening"]
                return self._render(Game.search.by_opening(firstm), 
                                    vopening=firstm, truncw=15)
        g = Game.objects.get(pk = request.POST.get('gamek'))
        land1, land2 = g.white.iflag, g.black.iflag
        land = land1 if ((land1 != land2) and 
                         (land1.code not in SPECIAL_LANDS)) else land2
        sname1, sname2 = g.white.name, g.black.name
        sname = sname1[:10] if sname1 not in SPECIAL_LANDS else sname2[:10]
        first4 = re.match(r'(1\..*) 5\.', g.pgn).group(1)
        return self._render(g, pgn_brw='pgn_brw', 
                            vland=land, vopening=first4, vplayer=sname)
        
list_games = GameView.as_view()  # pentru 'games/urls.py' (http://py3.hom/)  

Sintetizând, avem aici un exemplu de "view" (bazat pe 'TemplateView') care angajează un singur fişier şablon "games.html" conţinând patru formulare; acestea transmit prin HTTP POST fie 'name', fie 'land', 'opening' sau 'gamek' iar metoda post() a "view"-ului compilează câte o pagină de rezultate corespunzătoare acestora, actualizând în acelaşi timp valorile curente respective (fiind angajat un singur URL, http://py3.hom/).

18. Redarea grafică a desfăşurării unei partide

PGN-browser defineşte infrastructura necesară pentru vizualizarea în browser a desfăşurării unei partide de şah; avem de asigurat încărcarea fişierelor "pgnbrw.js" (care necesită jquery-1.11 şi jquery.ui.widget) şi "pgnbrw.css"; cele patru fişiere trebuie specificate în elementul <head> al şablonului "games.html". Apoi, în finalul elementului <script>, avem de ataşat un obiect pgnbrw() elementului <textarea> ("invizibil", având setat 'display:none') în care sunt postate mutările:

{#      modificări în chessg/templates/games.html (se integrează 'PGN-browser') #}
{% load static %}<!DOCTYPE html>
<html><head>
  <meta charset="utf-8" />
  <title>Partide de şah (PGN->R data.frame->PostgreSQL->Django, pgn_browser)</title>
  <link rel="stylesheet" href="{% static 'pgnbrw.css' %}">
  <script src="{% static 'JS/jquery-1.11.1.min.js' %}"></script>
  <script src="{% static 'JS/jquery.ui.widget.min.js' %}"></script>
  <script src="{% static 'JS/pgnbrw.js' %}"></script>
</head><body>
<div>
    {# ...  cele trei elemente <form> (Player, Land, Opening) #}
<div style="clear:left"></div></div>
{% if pgn_brw %}  {# "pagina specifică" partidei #}
    <p style="margin-left:3em;font-size:0.9em">
            <span dir="auto">{{object_list.white}}</span> - 
            <span dir="auto">{{object_list.black}}</span>
            <span>({{object_list.result}})</span></p>
    <div id="game_area" style="margin-left:4em">
        <textarea id="area_pgn" style="display:none">{{object_list.pgn}}</textarea>
    </div>
{% else %}
    <div style="height:500px;overflow:auto"><table>
        {% for game in object_list %}
            {# ... lista partidelor, după formularul prin care s-a cerut #}
    </table></div>
{% endif %}
<script>
    document.getElementById('id_player-name').value = "{{vplayer}}";
    {# ... actualizarea valorilor curente din <form>-uri #}
$(function() {
    {% if pgn_brw %}
        $('#area_pgn').pgnbrw({field_size:30, show_PGN:false, 
                               with_child:false, with_annotation:false});
    {% endif %}
});
</script>
</body></html>

Imaginile pieselor de şah (sprite-uri de câte 12 piese, pentru diverse dimensiuni N×N; aici am ales N=30 pixeli) sunt folosite în pgnbrv.css, iar fişierele imagine respective trebuie incluse într-un director chessg/static/images; pentru a le deservi prin Apache, includem în fişierul de configurare a site-ului (v. §4) directiva 'Alias  /images/  /home/vb/envPy3/chessg/static/images/'.

Am vizat de la bun început, fişiere PGN "curate" - fără adnotări (comentarii, sau variante de joc alăturate unora dintre mutări); în mod implicit (eliminând în scriptul de mai sus parametrii 'with_child' şi 'with_annotation'), pgnbrw() permite şi redarea separată a variantelor de joc existente în lista mutărilor (auto-instanţiindu-se într-o diviziune alăturată celei principale):

Am analizat partida vizată în imaginea de mai sus folosind Crafty şi am înscris PGN-ul obţinut (conţinând acum şi anumite variante de joc) în baza de date, prin interfaţa de administrare http://py3.hom/admin/ (dar în prealabil, am eliminat ca în §1, terminatorii de linie şi spaţiile inutile). În momentul când derularea partidei a ajuns la mutarea 23.Qb3, în dreapta diagramei principale s-a creat un nou obiect pgnbrw(), permiţând inspectarea variantelor de joc (şi vedem că 23.Qb3 a fost o greşeală, fiind apreciată negativ - iar Crafty a propus ca fiind mult mai bună, continuarea 23.B×c6).

Dar considerarea unor liste de mutări adnotate necesită anumite precauţii şi unele revederi ale lucrurilor; de exemplu, dacă prin adăugarea de variante şi comentarii, textul de înscris în câmpul 'pgn' al înregistrării respective din tabelul "games_game", devine prea lung - atunci avem de rezolvat o eroare ca 'index row size 3440 exceeds maximum 2712 for index...', dat fiind că în clasa 'Game' din "models.py" am prevăzut "unique_together" pentru câmpurile 'white_id', 'black_id' şi 'pgn', iar index-ul creat astfel de către PostgreSQL nu poate depăşi o anumită lungime fixată.

vezi Cărţile mele (de programare)

docerpro | Prev | Next