fix(pricelists): tolerate restricted DB grants and use embedded assets only
This commit is contained in:
@@ -88,6 +88,8 @@ Database: `RFQ_LOG`
|
|||||||
| `qt_categories` | Component categories | SELECT |
|
| `qt_categories` | Component categories | SELECT |
|
||||||
| `qt_pricelists` | Pricelists | SELECT |
|
| `qt_pricelists` | Pricelists | SELECT |
|
||||||
| `qt_pricelist_items` | Pricelist line items | SELECT |
|
| `qt_pricelist_items` | Pricelist line items | SELECT |
|
||||||
|
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
|
||||||
|
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
|
||||||
| `qt_configurations` | Saved configurations | SELECT, INSERT, UPDATE |
|
| `qt_configurations` | Saved configurations | SELECT, INSERT, UPDATE |
|
||||||
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
|
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
|
||||||
| `qt_client_local_migrations` | Migration catalog | SELECT only |
|
| `qt_client_local_migrations` | Migration catalog | SELECT only |
|
||||||
@@ -102,6 +104,8 @@ GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
|
|||||||
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.stock_log TO '<DB_USER>'@'%';
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
|
||||||
@@ -123,6 +127,8 @@ GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
|||||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.stock_log TO 'quote_user'@'%';
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
|
||||||
@@ -133,6 +139,8 @@ FLUSH PRIVILEGES;
|
|||||||
SHOW GRANTS FOR 'quote_user'@'%';
|
SHOW GRANTS FOR 'quote_user'@'%';
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note:** If pricelists sync but show `0` positions (or logs contain `enriching pricelist items with stock` + `SELECT denied`), verify `SELECT` on `lot_partnumbers` and `stock_log` in addition to `qt_pricelist_items`.
|
||||||
|
|
||||||
**Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%').
|
**Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%').
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -557,11 +557,11 @@ func runSetupMode(local *localdb.LocalDB) {
|
|||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(gin.Recovery())
|
router.Use(gin.Recovery())
|
||||||
|
|
||||||
staticPath := filepath.Join("web", "static")
|
if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||||
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
|
|
||||||
router.Static("/static", staticPath)
|
|
||||||
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
|
||||||
router.StaticFS("/static", http.FS(staticFS))
|
router.StaticFS("/static", http.FS(staticFS))
|
||||||
|
} else {
|
||||||
|
slog.Error("failed to load embedded static assets", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup routes only
|
// Setup routes only
|
||||||
@@ -847,11 +847,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
router.Use(middleware.OfflineDetector(connMgr, local))
|
router.Use(middleware.OfflineDetector(connMgr, local))
|
||||||
|
|
||||||
// Static files (use filepath.Join for Windows compatibility)
|
// Static files (use filepath.Join for Windows compatibility)
|
||||||
staticPath := filepath.Join("web", "static")
|
if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||||
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
|
|
||||||
router.Static("/static", staticPath)
|
|
||||||
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
|
||||||
router.StaticFS("/static", http.FS(staticFS))
|
router.StaticFS("/static", http.FS(staticFS))
|
||||||
|
} else {
|
||||||
|
return nil, nil, fmt.Errorf("load embedded static assets: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,7 +26,7 @@ type SetupHandler struct {
|
|||||||
restartSig chan struct{}
|
restartSig chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
|
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"sub": func(a, b int) int { return a - b },
|
"sub": func(a, b int) int { return a - b },
|
||||||
"add": func(a, b int) int { return a + b },
|
"add": func(a, b int) int { return a + b },
|
||||||
@@ -37,14 +35,9 @@ func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, te
|
|||||||
templates := make(map[string]*template.Template)
|
templates := make(map[string]*template.Template)
|
||||||
|
|
||||||
// Load setup template (standalone, no base needed)
|
// Load setup template (standalone, no base needed)
|
||||||
setupPath := filepath.Join(templatesPath, "setup.html")
|
|
||||||
var tmpl *template.Template
|
var tmpl *template.Template
|
||||||
var err error
|
var err error
|
||||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath)
|
|
||||||
} else {
|
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parsing setup template: %w", err)
|
return nil, fmt.Errorf("parsing setup template: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
stdsync "sync"
|
stdsync "sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,16 +30,9 @@ type SyncHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewSyncHandler creates a new sync handler
|
// NewSyncHandler creates a new sync handler
|
||||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string, autoSyncInterval time.Duration) (*SyncHandler, error) {
|
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, _ string, autoSyncInterval time.Duration) (*SyncHandler, error) {
|
||||||
// Load sync_status partial template
|
// Load sync_status partial template
|
||||||
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
tmpl, err := template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
||||||
var tmpl *template.Template
|
|
||||||
var err error
|
|
||||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
|
||||||
tmpl, err = template.ParseFiles(partialPath)
|
|
||||||
} else {
|
|
||||||
tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||||
@@ -17,7 +15,7 @@ type WebHandler struct {
|
|||||||
componentService *services.ComponentService
|
componentService *services.ComponentService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebHandler(templatesPath string, componentService *services.ComponentService) (*WebHandler, error) {
|
func NewWebHandler(_ string, componentService *services.ComponentService) (*WebHandler, error) {
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"sub": func(a, b int) int { return a - b },
|
"sub": func(a, b int) int { return a - b },
|
||||||
"add": func(a, b int) int { return a + b },
|
"add": func(a, b int) int { return a + b },
|
||||||
@@ -60,27 +58,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
templates := make(map[string]*template.Template)
|
templates := make(map[string]*template.Template)
|
||||||
basePath := filepath.Join(templatesPath, "base.html")
|
|
||||||
useDisk := false
|
|
||||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
|
||||||
useDisk = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load each page template with base
|
// Load each page template with base
|
||||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html"}
|
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html"}
|
||||||
for _, page := range simplePages {
|
for _, page := range simplePages {
|
||||||
pagePath := filepath.Join(templatesPath, page)
|
|
||||||
var tmpl *template.Template
|
var tmpl *template.Template
|
||||||
var err error
|
var err error
|
||||||
if useDisk {
|
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
qfassets.TemplatesFS,
|
||||||
} else {
|
"web/templates/base.html",
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
"web/templates/"+page,
|
||||||
qfassets.TemplatesFS,
|
)
|
||||||
"web/templates/base.html",
|
|
||||||
"web/templates/"+page,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -88,20 +75,14 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Index page needs components_list.html as well
|
// Index page needs components_list.html as well
|
||||||
indexPath := filepath.Join(templatesPath, "index.html")
|
|
||||||
componentsListPath := filepath.Join(templatesPath, "components_list.html")
|
|
||||||
var indexTmpl *template.Template
|
var indexTmpl *template.Template
|
||||||
var err error
|
var err error
|
||||||
if useDisk {
|
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
|
qfassets.TemplatesFS,
|
||||||
} else {
|
"web/templates/base.html",
|
||||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
"web/templates/index.html",
|
||||||
qfassets.TemplatesFS,
|
"web/templates/components_list.html",
|
||||||
"web/templates/base.html",
|
)
|
||||||
"web/templates/index.html",
|
|
||||||
"web/templates/components_list.html",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -110,17 +91,12 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
|||||||
// Load partial templates (no base needed)
|
// Load partial templates (no base needed)
|
||||||
partials := []string{"components_list.html"}
|
partials := []string{"components_list.html"}
|
||||||
for _, partial := range partials {
|
for _, partial := range partials {
|
||||||
partialPath := filepath.Join(templatesPath, partial)
|
|
||||||
var tmpl *template.Template
|
var tmpl *template.Template
|
||||||
var err error
|
var err error
|
||||||
if useDisk {
|
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath)
|
qfassets.TemplatesFS,
|
||||||
} else {
|
"web/templates/"+partial,
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
)
|
||||||
qfassets.TemplatesFS,
|
|
||||||
"web/templates/"+partial,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -245,8 +246,11 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
|||||||
items[i].Category = strings.TrimSpace(items[i].LotCategory)
|
items[i].Category = strings.TrimSpace(items[i].LotCategory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stock/partnumber enrichment is optional for pricelist item listing.
|
||||||
|
// Return base pricelist rows (lot_name/price/category) even when DB user lacks
|
||||||
|
// access to stock mapping tables (e.g. lot_partnumbers, stock_log).
|
||||||
if err := r.enrichItemsWithStock(items); err != nil {
|
if err := r.enrichItemsWithStock(items); err != nil {
|
||||||
return nil, 0, fmt.Errorf("enriching pricelist items with stock: %w", err)
|
slog.Warn("pricelist items stock enrichment skipped", "pricelist_id", pricelistID, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return items, total, nil
|
return items, total, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user