diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index f1d1f94..96674dc 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -20,7 +20,6 @@ import ( "ntech/internal/auth" "ntech/internal/be" "ntech/internal/config" - "ntech/internal/db" "ntech/internal/db/sqlite" "ntech/internal/handler" ntechmw "ntech/internal/middleware" @@ -152,7 +151,7 @@ func main() { h.JelDemo = os.Getenv("NTECH_ENV") == "demo" if h.JelDemo { h.Verzija = "DEMO - " + h.Verzija - if err := postaviDemoKorisnika(context.Background(), h.KorisniciRepo); err != nil { + if err := handler.PostaviDemoKorisnika(context.Background(), h.KorisniciRepo); err != nil { slog.Warn("demo: greška pri postavljanju demo korisnika", "error", err) } } @@ -338,6 +337,7 @@ func main() { r.Get("/podesavanja/backup", h.BackupBaze) r.With(doz("backup.pokreni")).Post("/podesavanja/backup/vrati", h.VratiBackup) + r.With(doz("backup.pokreni")).Post("/podesavanja/uvezi-bazu", h.UvezizBazu) // PDV evidencija — KIR (knjiga izdatih računa). Dostupno samo kada je modul // „pdv" uključen za firmu (RequireModul), uz RBAC dozvolu pdv.*. @@ -391,6 +391,7 @@ func main() { r.With(doz("kategorija.dodaj")).Post("/magacin/kategorije/dodaj", h.DodajKategoriju) r.With(doz("kategorija.izmeni")).Post("/magacin/kategorije/izmeni/{id}", h.IzmeniKategoriju) r.With(doz("kategorija.obrisi")).Post("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju) + r.With(doz("artikal.izmeni")).Post("/magacin/kategorije/dodeli-sifre", h.DodeliSifreArtiklima) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke", h.Nabavke) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/nova", h.NovaNabavka) r.With(doz("nabavka.dodaj")).Post("/nabavke/nova", h.SacuvajNabavku) @@ -556,39 +557,6 @@ func ucitajTotpKljuc(envFajl string) ([]byte, error) { return kljuc, nil } -// postaviDemoKorisnika osigurava da pri pokretanju demo instance postoji korisnik "Demo" -// sa ulogom "admin" i resetovanom lozinkom. Poziva se samo kada je NTECH_ENV=demo. -func postaviDemoKorisnika(ctx context.Context, repo db.KorisniciRepository) error { - const ( - demoIme = "Demo" - demoLozinka = "Demo1234" - ) - hash, err := auth.HashujLozinku(demoLozinka) - if err != nil { - return fmt.Errorf("ntech: postaviDemoKorisnika: %w", err) - } - korisnik, err := repo.DohvatiPoImenu(ctx, demoIme) - if err != nil { - // korisnik ne postoji — kreiraj ga - if _, err := repo.Kreiraj(ctx, demoIme, hash, "admin"); err != nil { - return fmt.Errorf("ntech: postaviDemoKorisnika: kreiranje: %w", err) - } - slog.Info("demo korisnik kreiran", "korisnik", demoIme) - return nil - } - // korisnik postoji — resetuj lozinku i osiguraj da je aktivan - if err := repo.PromeniLozinku(ctx, korisnik.ID, hash); err != nil { - return fmt.Errorf("ntech: postaviDemoKorisnika: lozinka: %w", err) - } - if !korisnik.Aktivan { - if err := repo.AzurirajAktivan(ctx, korisnik.ID, true); err != nil { - return fmt.Errorf("ntech: postaviDemoKorisnika: aktivan: %w", err) - } - } - slog.Info("demo korisnik resetovan", "korisnik", demoIme) - return nil -} - // napraviBackup kreira konzistentnu kopiju baze i briše najstarije preko zadatog broja kopija. // Koristi već otvorenu vezu ka bazi (VACUUM INTO je bezbedan na pooled konekciji). func napraviBackup(db *sql.DB, putanjaBaze string, maxKopija int) { diff --git a/internal/db/repository.go b/internal/db/repository.go index 18738fe..119c1c9 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -27,6 +27,8 @@ type ArtikalRepository interface { Izmeni(ctx context.Context, a *model.Artikal) error // AzurirajCene menja samo nabavnu i prodajnu cenu (kalkulacija pri nabavci) AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error + // AzurirajSifru menja samo šifru artikla + AzurirajSifru(ctx context.Context, id int64, sifra string) error PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error Obrisi(ctx context.Context, id int64) error // Arhiviraj označava artikal kao arhiviran (skriva ga iz aktivne liste, čuva istoriju) diff --git a/internal/db/sqlite/artikal.go b/internal/db/sqlite/artikal.go index 77dfef5..d6db643 100644 --- a/internal/db/sqlite/artikal.go +++ b/internal/db/sqlite/artikal.go @@ -304,6 +304,15 @@ func (r *ArtikalRepo) SledecaSifra(ctx context.Context, kategorijaID *int64) (st return fmt.Sprintf("%s-%04d", prefiks, maxBroj+1), nil } +// AzurirajSifru menja samo šifru artikla. +func (r *ArtikalRepo) AzurirajSifru(ctx context.Context, id int64, sifra string) error { + _, err := r.db.ExecContext(ctx, "UPDATE artikli SET sifra = ? WHERE id = ?", sifra, id) + if err != nil { + return fmt.Errorf("ntech: ArtikalRepo.AzurirajSifru: %w", err) + } + return nil +} + // AzurirajCene menja samo nabavnu i prodajnu cenu artikla (kalkulacija pri prijemu robe). func (r *ArtikalRepo) AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error { _, err := r.db.ExecContext(ctx, diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 3a0a42d..8efec75 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -4,6 +4,7 @@ import ( "html/template" "log/slog" "net/http" + "strings" "ntech/internal/auth" "ntech/internal/db/sqlite" @@ -104,13 +105,15 @@ func (h *Handler) AdminSacuvajKorisnika(w http.ResponseWriter, r *http.Request) return } - ime := r.FormValue("korisnicko_ime") + korisnickoIme := r.FormValue("korisnicko_ime") lozinka := r.FormValue("lozinka") uloga := r.FormValue("uloga") + ime := strings.TrimSpace(r.FormValue("ime")) + prezime := strings.TrimSpace(r.FormValue("prezime")) // superadmin uloga se ne može kreirati kroz interfejs — jedini superadmin postoji od setup-a validneUloge := map[string]bool{"admin": true, "radnik": true} - if len(ime) < 3 || len(lozinka) < 8 || !validneUloge[uloga] { + if len(korisnickoIme) < 3 || len(lozinka) < 8 || !validneUloge[uloga] || ime == "" || prezime == "" { middleware.SetFlash(w, r, h.DB, "greska", "Proverite unete podatke.") http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther) return @@ -123,7 +126,13 @@ func (h *Handler) AdminSacuvajKorisnika(w http.ResponseWriter, r *http.Request) return } - if _, err := h.KorisniciRepo.Kreiraj(r.Context(), ime, hash, uloga); err != nil { + noviKorisnik, err := h.KorisniciRepo.Kreiraj(r.Context(), korisnickoIme, hash, uloga) + if err != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.") + http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther) + return + } + if err := h.KorisniciRepo.AzurirajImePrezime(r.Context(), noviKorisnik.ID, ime, prezime); err != nil { middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.") http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther) return diff --git a/internal/handler/kategorija.go b/internal/handler/kategorija.go index daf5fc7..e4ac298 100644 --- a/internal/handler/kategorija.go +++ b/internal/handler/kategorija.go @@ -17,9 +17,11 @@ import ( // PodaciKategorija su podaci za stranicu kategorija type PodaciKategorija struct { model.PodaciStranice - Kategorije []model.Kategorija - Sacuvano bool - Obrisana bool + Kategorije []model.Kategorija + Sacuvano bool + Obrisana bool + SifreDodeljene int + PrikaziSifreDod bool } // Kategorije renderuje listu kategorija @@ -39,16 +41,53 @@ func (h *Handler) Kategorije(w http.ResponseWriter, r *http.Request) { ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "magacin" ps.NaslovStranice = "Kategorije" + sifreDodeljeneStr := r.URL.Query().Get("sifre_dodeljene") + sifreDodeljene, _ := strconv.Atoi(sifreDodeljeneStr) podaci := PodaciKategorija{ - PodaciStranice: ps, - Kategorije: kategorije, - Sacuvano: r.URL.Query().Get("sacuvano") == "1", - Obrisana: r.URL.Query().Get("obrisana") == "1", + PodaciStranice: ps, + Kategorije: kategorije, + Sacuvano: r.URL.Query().Get("sacuvano") == "1", + Obrisana: r.URL.Query().Get("obrisana") == "1", + SifreDodeljene: sifreDodeljene, + PrikaziSifreDod: sifreDodeljeneStr != "", } h.renderujTemplate(w, "kategorije", podaci) } +// DodeliSifreArtiklima prolazi kroz sve artikle bez šifre i dodeljuje im +// šifru na osnovu prefiksa njihove kategorije (isti generator kao za nove +// artikle). Radi sekvencijalno — jedan artikal se upiše pre nego što se za +// sledeći izračuna predlog — da artikli iz iste kategorije ne bi dobili isti broj. +func (h *Handler) DodeliSifreArtiklima(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni"); !ok { + return + } + + artikli, err := h.Artikli.Lista(r.Context(), db.ArtikalFilter{}) + if err != nil { + http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError) + return + } + + broj := 0 + for _, a := range artikli { + if a.Sifra != "" { + continue + } + sifra, err := h.Artikli.SledecaSifra(r.Context(), a.KategorijaID) + if err != nil { + continue + } + if err := h.Artikli.AzurirajSifru(r.Context(), a.ID, sifra); err != nil { + continue + } + broj++ + } + + http.Redirect(w, r, "/magacin/kategorije?sacuvano=1&sifre_dodeljene="+strconv.Itoa(broj), http.StatusSeeOther) +} + // DodajKategoriju prima POST i čuva novu kategoriju func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "kategorija.dodaj"); !ok { diff --git a/internal/handler/magacin_forma.go b/internal/handler/magacin_forma.go index eb39f02..5c6aaa9 100644 --- a/internal/handler/magacin_forma.go +++ b/internal/handler/magacin_forma.go @@ -23,8 +23,11 @@ type PodaciFormeArtikla struct { KategorijaIDStr string Dobavljaci []model.Dobavljac // svi dobavljači za izbor IzabraniDobavljaci map[int64]bool // dobavljači vezani za artikal (za checked stanje) - Greska string - Izmena bool + PredlogSifre string // prikazuje se kao placeholder, NIKAD kao stvarna vrednost polja — + // polje šifre mora ostati prazno da bi server na čuvanju sam izračunao + // tačan prefiks na osnovu izabrane kategorije (vidi SacuvajArtikal) + Greska string + Izmena bool } // NoviArtikal prikazuje formu za unos novog artikla @@ -62,7 +65,8 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) { PodaciStranice: ps, Kategorije: kategorije, Dobavljaci: dobavljaci, - Artikal: model.Artikal{Sifra: predlogSifre, Tip: tip, JedinicaMere: "kom", PdvStopa: h.podrazumevanaPdvStopa(r.Context())}, + Artikal: model.Artikal{Tip: tip, JedinicaMere: "kom", PdvStopa: h.podrazumevanaPdvStopa(r.Context())}, + PredlogSifre: predlogSifre, Izmena: false, }) } diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index fde4584..795c559 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -1,7 +1,9 @@ package handler import ( + "context" "crypto/rand" + "database/sql" "encoding/hex" "fmt" "html" @@ -20,11 +22,219 @@ import ( "encoding/json" + "ntech/internal/auth" + "ntech/internal/db" ntechsqlite "ntech/internal/db/sqlite" "ntech/internal/middleware" "ntech/internal/model" ) +// PostaviDemoKorisnika osigurava da postoji korisnik "Demo" sa ulogom "admin" +// i podrazumevanom lozinkom. Poziva se pri startu demo instance, i ponovo +// posle uvoza/vraćanja baze (jer taj hot-swap donosi svoje korisnike iz +// uvezenog/vraćenog fajla, koji mogu imati drugačiju ili nepoznatu lozinku). +func PostaviDemoKorisnika(ctx context.Context, repo db.KorisniciRepository) error { + const ( + demoIme = "Demo" + demoLozinka = "Demo1234" + ) + hash, err := auth.HashujLozinku(demoLozinka) + if err != nil { + return fmt.Errorf("ntech: PostaviDemoKorisnika: %w", err) + } + korisnik, err := repo.DohvatiPoImenu(ctx, demoIme) + if err != nil { + // korisnik ne postoji — kreiraj ga + if _, err := repo.Kreiraj(ctx, demoIme, hash, "admin"); err != nil { + return fmt.Errorf("ntech: PostaviDemoKorisnika: kreiranje: %w", err) + } + slog.Info("demo korisnik kreiran", "korisnik", demoIme) + return nil + } + // korisnik postoji — resetuj lozinku i osiguraj da je aktivan + if err := repo.PromeniLozinku(ctx, korisnik.ID, hash); err != nil { + return fmt.Errorf("ntech: PostaviDemoKorisnika: lozinka: %w", err) + } + if !korisnik.Aktivan { + if err := repo.AzurirajAktivan(ctx, korisnik.ID, true); err != nil { + return fmt.Errorf("ntech: PostaviDemoKorisnika: aktivan: %w", err) + } + } + slog.Info("demo korisnik resetovan", "korisnik", demoIme) + return nil +} + +// lokalniKljuceviPodesavanja su ključevi iz tabele podesavanja koji opisuju +// OVU instalaciju/server (izgled login ekrana, adresa fiskalnog uređaja, +// raspored backupa...), a ne podatke firme. Pri uvozu ili vraćanju baze sa +// drugog računara ovi ključevi se NE prepisuju uvezenim vrednostima — server +// zadržava svoje. Sve ostalo u podesavanja smatra se podacima firme i putuje +// sa uvezenom/vraćenom bazom. +var lokalniKljuceviPodesavanja = []string{ + "backup_interval_sati", + "backup_broj_kopija", + "login_pozadina", + "login_pozadina_opacity", + "login_pozadina_blur_pozadine", + "login_pozadina_blur_kartice", + "login_pozadina_zatamnjenje_kartice", + "topbar_logo_slika", + "topbar_logo_tekst", + "pfr_url", + "pfr_tip", + "qr_bazni_url", + "verify_host", +} + +// ucitajLokalnaPodesavanja čita trenutne vrednosti lokalnih ključeva iz baze +// pre nego što se baza zameni uvozom/vraćanjem backupa. +func ucitajLokalnaPodesavanja(ctx context.Context, db *sql.DB) map[string]string { + vrednosti := make(map[string]string, len(lokalniKljuceviPodesavanja)) + for _, kljuc := range lokalniKljuceviPodesavanja { + var vrednost string + if err := db.QueryRowContext(ctx, "SELECT vrednost FROM podesavanja WHERE kljuc = ?", kljuc).Scan(&vrednost); err == nil { + vrednosti[kljuc] = vrednost + } + } + return vrednosti +} + +// upisiLokalnaPodesavanja upisuje sačuvane lokalne vrednosti nazad u bazu +// posle zamene, prepisujući preko onoga što je došlo iz uvezene/vraćene baze. +func upisiLokalnaPodesavanja(ctx context.Context, db *sql.DB, vrednosti map[string]string) { + for kljuc, vrednost := range vrednosti { + if _, err := db.ExecContext(ctx, + "INSERT INTO podesavanja (kljuc, vrednost) VALUES (?, ?) ON CONFLICT(kljuc) DO UPDATE SET vrednost = excluded.vrednost", + kljuc, vrednost); err != nil { + slog.Error("upis lokalnih podešavanja: greška", "kljuc", kljuc, "error", err) + } + } +} + +// lokalneTabelePodesavanja su tabele koje opisuju podešavanja OVOG servera, a +// ne poslovne podatke. Korisnici NAMERNO nisu ovde: poslovni podaci (servisni +// nalozi, magacinske promene...) referenciraju konkretne korisnike preko ID-a +// (ko je uradio nalog, ko je izvršio promenu), pa moraju putovati zajedno — +// razdvajanje bi ili pokidalo FK ili tiho pripisalo tuđe podatke pogrešnoj +// osobi. Zato korisnici putuju sa uvezenom/vraćenom bazom kao i ostali +// poslovni podaci; samo dozvole (permisije po ulozi, bez FK ka korisnicima) +// ostaju lokalne. +var lokalneTabelePodesavanja = []string{ + "dozvole", +} + +// volatilneTabeleBezbednosti su sesije i srodno runtime/bezbednosno stanje +// koje referencira korisnike preko FK. Pošto korisnici putuju sa uvezenom +// bazom (vidi lokalneTabelePodesavanja), staro stanje ovih tabela bi posle +// zamene moglo da referencira nepostojeće ili pogrešne korisnike — zato se +// jednostavno brišu (ne vraćaju iz snimka), što tera sve na ponovnu prijavu. +var volatilneTabeleBezbednosti = []string{ + "webauthn_kredencijali", + "rezervni_kodovi", + "login_istorija", + "sesije", + "pokusaji_prijave", +} + +// ocistiVolatilneTabele briše sesije i srodno bezbednosno stanje posle zamene +// baze — vidi volatilneTabeleBezbednosti. +func ocistiVolatilneTabele(ctx context.Context, db *sql.DB) { + for _, tabela := range volatilneTabeleBezbednosti { + if _, err := db.ExecContext(ctx, "DELETE FROM "+tabela); err != nil { + slog.Error("čišćenje volatilnih tabela: greška", "tabela", tabela, "error", err) + } + } +} + +// tabelaSnimak čuva sve redove jedne tabele (generički, bez fiksne šeme) radi +// vraćanja posle zamene baze. +type tabelaSnimak struct { + kolone []string + redovi [][]any +} + +// ucitajTabelu učitava sve redove i imena kolona zadate tabele. +func ucitajTabelu(ctx context.Context, db *sql.DB, tabela string) ([]string, [][]any, error) { + rows, err := db.QueryContext(ctx, "SELECT * FROM "+tabela) + if err != nil { + return nil, nil, err + } + defer rows.Close() + + kolone, err := rows.Columns() + if err != nil { + return nil, nil, err + } + + var redovi [][]any + for rows.Next() { + vrednosti := make([]any, len(kolone)) + pokazivaci := make([]any, len(kolone)) + for i := range vrednosti { + pokazivaci[i] = &vrednosti[i] + } + if err := rows.Scan(pokazivaci...); err != nil { + return nil, nil, err + } + redovi = append(redovi, vrednosti) + } + return kolone, redovi, rows.Err() +} + +// ucitajLokalneTabele čita trenutni sadržaj naloga/bezbednosnih tabela pre +// zamene baze. +func ucitajLokalneTabele(ctx context.Context, db *sql.DB) map[string]tabelaSnimak { + snimci := make(map[string]tabelaSnimak, len(lokalneTabelePodesavanja)) + for _, tabela := range lokalneTabelePodesavanja { + kolone, redovi, err := ucitajTabelu(ctx, db, tabela) + if err != nil { + slog.Error("čuvanje lokalnih tabela: greška pri čitanju", "tabela", tabela, "error", err) + continue + } + snimci[tabela] = tabelaSnimak{kolone: kolone, redovi: redovi} + } + return snimci +} + +// upisiLokalneTabele vraća sačuvani sadržaj naloga/bezbednosnih tabela posle +// zamene baze, brišući ono što je stiglo iz uvezene/vraćene baze. +func upisiLokalneTabele(ctx context.Context, db *sql.DB, snimci map[string]tabelaSnimak) { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + slog.Error("upis lokalnih tabela: greška pri otvaranju transakcije", "error", err) + return + } + defer tx.Rollback() + + for i := len(lokalneTabelePodesavanja) - 1; i >= 0; i-- { + tabela := lokalneTabelePodesavanja[i] + if _, err := tx.ExecContext(ctx, "DELETE FROM "+tabela); err != nil { + slog.Error("upis lokalnih tabela: greška pri brisanju", "tabela", tabela, "error", err) + return + } + } + + for _, tabela := range lokalneTabelePodesavanja { + snimak, ok := snimci[tabela] + if !ok || len(snimak.redovi) == 0 { + continue + } + upit := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", + tabela, strings.Join(snimak.kolone, ", "), + strings.TrimSuffix(strings.Repeat("?, ", len(snimak.kolone)), ", ")) + for _, red := range snimak.redovi { + if _, err := tx.ExecContext(ctx, upit, red...); err != nil { + slog.Error("upis lokalnih tabela: greška pri upisu", "tabela", tabela, "error", err) + return + } + } + } + + if err := tx.Commit(); err != nil { + slog.Error("upis lokalnih tabela: greška pri komitovanju", "error", err) + } +} + // podrazumevaniUsloviServisa je podrazumevani tekst uslova servisa koji se // štampa na reversu; korisnik ga može izmeniti u Podešavanja → Servis. const podrazumevaniUsloviServisa = `1. Servis ne odgovara za podatke na uređaju. Klijent je dužan da sam napravi rezervnu kopiju podataka pre predaje. @@ -215,6 +425,9 @@ func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) { slog.Error("vrati backup: wal_checkpoint greška", "error", err) } + lokalnaPodesavanja := ucitajLokalnaPodesavanja(r.Context(), h.DB) + lokalneTabele := ucitajLokalneTabele(r.Context(), h.DB) + // Sama zamena baze (zatvaranje stare, kopiranje, otvaranje nove) radi se u // zasebnoj gorutini pod EKSKLUZIVNIM zaključavanjem (h.mu.Lock). Razlog: ovaj // zahtev još drži deljeno zaključavanje (ZakljucajCitanje middleware), pa bi @@ -242,7 +455,15 @@ func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) { slog.Error("vrati backup: greška pri otvaranju nove baze (potreban restart)", "error", err) return } + upisiLokalnaPodesavanja(context.Background(), novaDB, lokalnaPodesavanja) + upisiLokalneTabele(context.Background(), novaDB, lokalneTabele) + ocistiVolatilneTabele(context.Background(), novaDB) h.reinicijalizujRepozitorijume(novaDB) + if h.JelDemo { + if err := PostaviDemoKorisnika(context.Background(), h.KorisniciRepo); err != nil { + slog.Error("vrati backup: greška pri resetovanju demo korisnika", "error", err) + } + } slog.Info("baza uspešno obnovljena", "izvor", ime) }() @@ -267,6 +488,105 @@ func kopiraFajl(izvor, odrediste string) error { return err } +// UvezizBazu zamenjuje trenutnu bazu fajlom koji korisnik otpremi sa svog računara +func (h *Handler) UvezizBazu(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "backup.pokreni"); !ok { + return + } + + const maxVelicinaUvoza = 500 << 20 // 500 MB + r.Body = http.MaxBytesReader(w, r.Body, maxVelicinaUvoza) + if err := r.ParseMultipartForm(32 << 20); err != nil { + http.Redirect(w, r, "/podesavanja?backup_greska=Fajl+je+prevelik+ili+zahtev+nije+ispravan", http.StatusSeeOther) + return + } + + fajl, _, err := r.FormFile("baza") + if err != nil { + http.Redirect(w, r, "/podesavanja?backup_greska=Fajl+nije+izabran", http.StatusSeeOther) + return + } + defer fajl.Close() + + zaglavlje := make([]byte, 16) + if _, err := io.ReadFull(fajl, zaglavlje); err != nil || string(zaglavlje) != "SQLite format 3\x00" { + http.Redirect(w, r, "/podesavanja?backup_greska=Fajl+nije+ispravna+SQLite+baza", http.StatusSeeOther) + return + } + + privremena := filepath.Join("backups", fmt.Sprintf("uvoz_%s.db", time.Now().Format("20060102_150405"))) + odrediste, err := os.Create(privremena) + if err != nil { + slog.Error("uvezi bazu: greška pri kreiranju privremenog fajla", "error", err) + http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+čuvanju+otpremljenog+fajla", http.StatusSeeOther) + return + } + if _, err := odrediste.Write(zaglavlje); err != nil { + odrediste.Close() + http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+čuvanju+otpremljenog+fajla", http.StatusSeeOther) + return + } + if _, err := io.Copy(odrediste, fajl); err != nil { + odrediste.Close() + slog.Error("uvezi bazu: greška pri čuvanju otpremljenog fajla", "error", err) + http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+čuvanju+otpremljenog+fajla", http.StatusSeeOther) + return + } + odrediste.Close() + + // pre uvoza, sačuvaj trenutno stanje baze + sigurnosni := filepath.Join("backups", fmt.Sprintf("ntech_%s_pred_uvozom.db", time.Now().Format("20060102_150405"))) + if _, err := h.DB.ExecContext(r.Context(), "VACUUM INTO ?", sigurnosni); err != nil { + slog.Error("uvezi bazu: greška pri kreiranju sigurnosne kopije", "error", err) + os.Remove(privremena) + http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+kreiranju+sigurnosne+kopije", http.StatusSeeOther) + return + } + + // isprazni WAL u glavni fajl + if _, err := h.DB.ExecContext(r.Context(), "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { + slog.Error("uvezi bazu: wal_checkpoint greška", "error", err) + } + + lokalnaPodesavanja := ucitajLokalnaPodesavanja(r.Context(), h.DB) + lokalneTabele := ucitajLokalneTabele(r.Context(), h.DB) + + // zamena baze radi se pod ekskluzivnim zaključavanjem — isti obrazac kao VratiBackup + go func() { + h.mu.Lock() + defer h.mu.Unlock() + defer os.Remove(privremena) + + if err := h.DB.Close(); err != nil { + slog.Error("uvezi bazu: greška pri zatvaranju baze", "error", err) + } + if err := kopiraFajl(privremena, h.PutanjaBaze); err != nil { + slog.Error("uvezi bazu: greška pri kopiranju (baza je zatvorena, potreban restart)", "error", err) + return + } + os.Remove(h.PutanjaBaze + "-wal") + os.Remove(h.PutanjaBaze + "-shm") + + novaDB, err := ntechsqlite.OtvoriDB(h.PutanjaBaze) + if err != nil { + slog.Error("uvezi bazu: greška pri otvaranju nove baze (potreban restart)", "error", err) + return + } + upisiLokalnaPodesavanja(context.Background(), novaDB, lokalnaPodesavanja) + upisiLokalneTabele(context.Background(), novaDB, lokalneTabele) + ocistiVolatilneTabele(context.Background(), novaDB) + h.reinicijalizujRepozitorijume(novaDB) + if h.JelDemo { + if err := PostaviDemoKorisnika(context.Background(), h.KorisniciRepo); err != nil { + slog.Error("uvezi bazu: greška pri resetovanju demo korisnika", "error", err) + } + } + slog.Info("baza uspešno uvezena") + }() + + http.Redirect(w, r, "/podesavanja?sacuvano=vraceno", http.StatusSeeOther) +} + // validirajMaticniBroj proverava da li uneti matični broj ima tačno 8 cifara. // Prazan broj je dozvoljen (polje nije obavezno). Vraća srpsku poruku o grešci // ili prazan string ako je broj ispravan. diff --git a/web/templates/stranice/admin_korisnici.html b/web/templates/stranice/admin_korisnici.html index dc0c69f..771e32f 100644 --- a/web/templates/stranice/admin_korisnici.html +++ b/web/templates/stranice/admin_korisnici.html @@ -14,7 +14,7 @@
-
+
Korisnici sistema
@@ -81,10 +81,10 @@ {{if eq .Uloga "radnik"}} -
+
@@ -103,8 +103,18 @@
Novi korisnik
-
+
+
+ + +
+
+ + +
✓ Dvostepena verifikacija je uključena
Prijava zahteva TOTP kod pored lozinke.
- - + +
@@ -105,8 +105,8 @@ {{ else }}
Rezervni kodovi: preostalo {{ .BrojRezervnih }}. Koriste se za prijavu kada nemate pristup aplikaciji sa kodovima.
{{ end }} -
- + +
diff --git a/web/templates/stranice/blokirane_ip.html b/web/templates/stranice/blokirane_ip.html index f938f67..aa907cb 100644 --- a/web/templates/stranice/blokirane_ip.html +++ b/web/templates/stranice/blokirane_ip.html @@ -30,11 +30,11 @@ {{.PoslednjiPokusaj.Format "02.01.2006. 15:04:05"}} {{.Preostalo}} -
+ - +
diff --git a/web/templates/stranice/fiskal_pazar.html b/web/templates/stranice/fiskal_pazar.html index df51c62..974df50 100644 --- a/web/templates/stranice/fiskal_pazar.html +++ b/web/templates/stranice/fiskal_pazar.html @@ -93,10 +93,10 @@ Osveži {{if index .Dozvole "fiskal.zakljucenje"}} -
+ - +
{{end}}
diff --git a/web/templates/stranice/kategorije.html b/web/templates/stranice/kategorije.html index d5f1324..44c96f5 100644 --- a/web/templates/stranice/kategorije.html +++ b/web/templates/stranice/kategorije.html @@ -25,12 +25,27 @@ {{if .Obrisana}}
Kategorija je uspešno obrisana.
{{end}} + {{if .PrikaziSifreDod}} +
+ {{if eq .SifreDodeljene 0}}Svi artikli već imaju šifru.{{else}}Dodeljena šifra za {{.SifreDodeljene}} artikal(a).{{end}} +
+ {{end}} - - - Nazad na magacin - +
+ + + Nazad na magacin + + {{if index $.Dozvole "artikal.izmeni"}} +
+ +
+ {{end}} +
{{if index $.Dozvole "kategorija.dodaj"}} diff --git a/web/templates/stranice/magacin_forma.html b/web/templates/stranice/magacin_forma.html index 434a78b..ce54ecf 100644 --- a/web/templates/stranice/magacin_forma.html +++ b/web/templates/stranice/magacin_forma.html @@ -30,7 +30,7 @@
Ako ostaviš prazno, šifra se automatski dodeljuje.
diff --git a/web/templates/stranice/podesavanja.html b/web/templates/stranice/podesavanja.html index da29a57..35f0d3d 100644 --- a/web/templates/stranice/podesavanja.html +++ b/web/templates/stranice/podesavanja.html @@ -303,11 +303,11 @@ {{.Datum}} {{.Velicina}} -
+
diff --git a/web/templates/stranice/podesavanja_sistem.html b/web/templates/stranice/podesavanja_sistem.html index dd41a19..58d51c9 100644 --- a/web/templates/stranice/podesavanja_sistem.html +++ b/web/templates/stranice/podesavanja_sistem.html @@ -26,6 +26,16 @@ Vrati backup baze + + @@ -77,11 +87,11 @@ {{.Datum}} {{.Velicina}} -
+