Skip to content

Mini projekt: Knjižnica

Zakaj mini projekt

Mini projekt je točka, kjer se posamezna poglavja končno združijo. Cilj ni zgraditi kompleksnega informacijskega sistema, ampak eno majhno, čisto in razumljivo spletno aplikacijo, ki pokaže celoten tok:

  • HTML struktura,
  • CSS videz,
  • obrazec,
  • Flask backend,
  • SQLite baza,
  • prikaz podatkov.

Funkcionalni cilj projekta

Projekt “Knjižnica” naj omogoča:

  • prikaz seznama knjig,
  • dodajanje nove knjige,
  • osnovno validacijo,
  • shranjevanje v SQLite,
  • prikaz napak in uspešen povratek na seznam.

Struktura projekta

knjiznica/
├─ app.py
├─ schema.sql
├─ templates/
│  ├─ base.html
│  ├─ index.html
│  └─ dodaj.html
└─ static/
   └─ style.css

schema.sql

DROP TABLE IF EXISTS knjige;

CREATE TABLE knjige (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    naslov TEXT NOT NULL,
    avtor TEXT NOT NULL,
    leto INTEGER
);

app.py

import sqlite3
from pathlib import Path
from flask import Flask, g, redirect, render_template, request, url_for

BASE_DIR = Path(__file__).resolve().parent
DATABASE = BASE_DIR / "knjiznica.db"

app = Flask(__name__)


def get_db():
    if "db" not in g:
        g.db = sqlite3.connect(DATABASE)
        g.db.row_factory = sqlite3.Row
    return g.db


@app.teardown_appcontext
def close_db(exception):
    db = g.pop("db", None)
    if db is not None:
        db.close()


def init_db():
    db = sqlite3.connect(DATABASE)
    schema = (BASE_DIR / "schema.sql").read_text(encoding="utf-8")
    db.executescript(schema)
    db.commit()
    db.close()


@app.route("/")
def index():
    db = get_db()
    knjige = db.execute(
        "SELECT id, naslov, avtor, leto FROM knjige ORDER BY naslov"
    ).fetchall()
    return render_template("index.html", knjige=knjige)


@app.route("/dodaj", methods=["GET", "POST"])
def dodaj():
    if request.method == "POST":
        naslov = request.form.get("naslov", "").strip()
        avtor = request.form.get("avtor", "").strip()
        leto_raw = request.form.get("leto", "").strip()

        if not naslov or not avtor:
            return render_template(
                "dodaj.html",
                napaka="Naslov in avtor sta obvezna.",
                vrednosti={"naslov": naslov, "avtor": avtor, "leto": leto_raw},
            )

        leto = int(leto_raw) if leto_raw else None

        db = get_db()
        db.execute(
            "INSERT INTO knjige (naslov, avtor, leto) VALUES (?, ?, ?)",
            (naslov, avtor, leto),
        )
        db.commit()
        return redirect(url_for("index"))

    return render_template("dodaj.html", vrednosti={"naslov": "", "avtor": "", "leto": ""})


if __name__ == "__main__":
    if not DATABASE.exists():
        init_db()
    app.run(debug=True)

Predloge

=== "templates/base.html"

```html
<!doctype html>
<html lang="sl">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{% block title %}Knjižnica{% endblock %}</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
  <header class="site-header">
    <div class="container">
      <h1>Moja knjižnica</h1>
      <nav aria-label="Glavna navigacija">
        <a href="{{ url_for('index') }}">Domov</a>
        <a href="{{ url_for('dodaj') }}">Dodaj knjigo</a>
      </nav>
    </div>
  </header>

  <main class="container">
    {% block content %}{% endblock %}
  </main>
</body>
</html>
```

=== "templates/index.html"

```html
{% extends "base.html" %}

{% block title %}Seznam knjig{% endblock %}

{% block content %}
  <section class="panel">
    <div class="panel-head">
      <h2>Seznam knjig</h2>
      <a class="button-link" href="{{ url_for('dodaj') }}">Dodaj novo knjigo</a>
    </div>

    {% if knjige %}
      <table class="data-table">
        <thead>
          <tr>
            <th>Naslov</th>
            <th>Avtor</th>
            <th>Leto</th>
          </tr>
        </thead>
        <tbody>
          {% for knjiga in knjige %}
            <tr>
              <td>{{ knjiga["naslov"] }}</td>
              <td>{{ knjiga["avtor"] }}</td>
              <td>{{ knjiga["leto"] or "—" }}</td>
            </tr>
          {% endfor %}
        </tbody>
      </table>
    {% else %}
      <p>V bazi še ni knjig.</p>
    {% endif %}
  </section>
{% endblock %}
```

=== "templates/dodaj.html"

```html
{% extends "base.html" %}

{% block title %}Dodaj knjigo{% endblock %}

{% block content %}
  <section class="panel">
    <h2>Dodaj knjigo</h2>

    {% if napaka %}
      <p class="error">{{ napaka }}</p>
    {% endif %}

    <form class="book-form" action="{{ url_for('dodaj') }}" method="post">
      <div>
        <label for="naslov">Naslov</label>
        <input
          id="naslov"
          name="naslov"
          type="text"
          required
          value="{{ vrednosti.naslov }}">
      </div>

      <div>
        <label for="avtor">Avtor</label>
        <input
          id="avtor"
          name="avtor"
          type="text"
          required
          value="{{ vrednosti.avtor }}">
      </div>

      <div>
        <label for="leto">Leto izida</label>
        <input
          id="leto"
          name="leto"
          type="number"
          min="0"
          value="{{ vrednosti.leto }}">
      </div>

      <button type="submit">Shrani knjigo</button>
    </form>
  </section>
{% endblock %}
```

static/style.css

* {
  box-sizing: border-box;
}

body {
  font-family: system-ui, sans-serif;
  line-height: 1.6;
  margin: 0;
  background: #f7f7f8;
  color: #222;
}

.container {
  width: min(100% - 2rem, 70rem);
  margin: 0 auto;
}

.site-header {
  background: #1f2937;
  color: white;
  padding: 1rem 0;
}

.site-header .container {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.site-header a {
  color: white;
  text-decoration: none;
  margin-right: 1rem;
}

main.container {
  padding: 1.5rem 0;
}

.panel {
  background: white;
  padding: 1rem;
  border-radius: 0.75rem;
  box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.06);
}

.panel-head {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  margin-bottom: 1rem;
}

.button-link,
button {
  display: inline-block;
  border: 0;
  border-radius: 0.5rem;
  padding: 0.75rem 1rem;
  text-decoration: none;
  cursor: pointer;
}

button {
  width: fit-content;
}

.book-form {
  display: grid;
  gap: 1rem;
  max-width: 32rem;
}

.book-form label {
  display: block;
  font-weight: 600;
  margin-bottom: 0.35rem;
}

.book-form input {
  width: 100%;
  padding: 0.7rem;
}

.data-table {
  width: 100%;
  border-collapse: collapse;
}

.data-table th,
.data-table td {
  text-align: left;
  padding: 0.75rem;
  border-bottom: 1px solid #ddd;
}

.error {
  padding: 0.75rem;
  border-radius: 0.5rem;
  background: #fee2e2;
  margin-bottom: 1rem;
}

@media (min-width: 768px) {
  .site-header .container,
  .panel-head {
    flex-direction: row;
    justify-content: space-between;
    align-items: center;
  }
}

Kako projekt razložiti po korakih

Namesto da dijakom takoj pokažeš vse datoteke, je boljša naslednja pot:

  1. najprej pokaži schema.sql,
  2. nato najpreprostejši app.py,
  3. nato index.html,
  4. nato dodaj.html,
  5. nato CSS,
  6. nato validacijo in preusmeritev.

S tem jim zmanjšaš občutek, da je projekt “prevelik”.

Kaj se dijak nauči iz tega projekta

S tem projektom se dijak nauči:

  • organizirati majhen projekt po mapah,
  • napisati osnovno podatkovno shemo,
  • prebrati in prikazati podatke iz baze,
  • oddati obrazec,
  • validirati osnovna polja,
  • shraniti podatke v SQLite,
  • uporabiti dedovanje predlog,
  • uporabiti osnovno odzivno postavitev.

Možne razširitve

Če ostane čas, lahko projekt razširiš z:

  • iskanjem po naslovu,
  • filtriranjem po avtorju,
  • urejanjem zapisa,
  • brisanjem zapisa,
  • ločeno tabelo avtorji,
  • preprosto statistiko števila knjig.

Tipične napake pri projektu

  • napačna imena polj v HTML,
  • pozabljen commit(),
  • napačna pot do CSS,
  • manjkajoča mapa templates,
  • pretirana panika ob prvi 500 napaki.

Minimalen kriterij uspeha

Projekt je za osnovni nivo uspešen, če:

  • se aplikacija zažene,
  • seznam knjig deluje,
  • dodajanje deluje,
  • podatki ostanejo v bazi,
  • obrazec vrne razumljivo napako pri praznih poljih.

Realno merilo

Za 16-urni sklop je to že zelo lep in pošten rezultat. Ni treba graditi “Amazon za knjige”, da bi bil predmet uspešen.