Merge pull request 'Produit minimal viable' (#1) from victor/wip into main
Reviewed-on: #1
This commit is contained in:
commit
db07eed236
19 changed files with 635 additions and 85 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
.env
|
51
cmd.go
Normal file
51
cmd.go
Normal file
|
@ -0,0 +1,51 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// rootCmd
|
||||
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file (default is $HOME/.bottin.agendas.yaml)")
|
||||
|
||||
if err := BindFlags(rootCmd.PersistentFlags()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "agendas",
|
||||
//Args: cobra.(0),
|
||||
Short: "Gestion de distribution d'agendas",
|
||||
Run: func(c *cobra.Command, args []string) {
|
||||
log.Println("Starting bottin agendas microservice")
|
||||
|
||||
ctx := context.TODO()
|
||||
|
||||
cfg, err := ParseConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if len(args) > 0 {
|
||||
if args[0] == "debugConfig" {
|
||||
log.Println("debug: Printing current config")
|
||||
cfgAsBytes, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
log.Fatal("Cannot marshal config for printing:", err)
|
||||
}
|
||||
fmt.Println(string(cfgAsBytes))
|
||||
}
|
||||
}
|
||||
|
||||
if err := run(ctx, cfg); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
16
compose.yaml
Normal file
16
compose.yaml
Normal file
|
@ -0,0 +1,16 @@
|
|||
name: 'agendas-db'
|
||||
services:
|
||||
db:
|
||||
image: 'postgres:16'
|
||||
environment:
|
||||
POSTGRES_USER: "${DB_USER:?}"
|
||||
POSTGRES_PASSWORD: "${DB_PASSWORD:?}"
|
||||
POSTGRES_DATABASE: "${DB_DATABASE:?}"
|
||||
restart: 'unless-stopped'
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- 'db-data:/var/lib/postgresql/data/'
|
||||
- '/etc/localtime:/etc/localtime:ro'
|
||||
volumes:
|
||||
db-data:
|
85
config.go
85
config.go
|
@ -1,7 +1,90 @@
|
|||
package main
|
||||
|
||||
import "git.agecem.com/bottin/bottin/v10/pkg/bottin"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.agecem.com/bottin/bottin/v10/pkg/bottin"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// Bottin holds a client config to contact a bottin API server
|
||||
// Cannot be set using environment variables
|
||||
Bottin bottin.APIClientConfig
|
||||
|
||||
DB struct {
|
||||
Database string
|
||||
Host string
|
||||
Password string
|
||||
Port int
|
||||
SSLMode string
|
||||
Username string
|
||||
}
|
||||
|
||||
// TLS holds options for TLS / SSL / HTTPS
|
||||
TLS struct {
|
||||
// Cert holds the public certificate (or path to a file containing one) for user interface TLS
|
||||
Cert string
|
||||
|
||||
// Key holds the private key (or path to a file containing one) for user interface TLS
|
||||
Key string
|
||||
|
||||
// Enabled holds the TLS activation state
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Credentials holds username-password pairs for basic authentication
|
||||
Credentials map[string]string
|
||||
|
||||
// Port holds the port on which to expose the user interface
|
||||
Port int
|
||||
}
|
||||
|
||||
func ParseConfig() (cfg Config, err error) {
|
||||
return cfg, viper.Unmarshal(&cfg)
|
||||
}
|
||||
|
||||
var cfgFile string
|
||||
|
||||
// initConfig reads in config file and ENV variables if set.
|
||||
func initConfig() {
|
||||
if cfgFile != "" {
|
||||
// Use config file from the flag.
|
||||
viper.SetConfigFile(cfgFile)
|
||||
} else {
|
||||
// Find home directory.
|
||||
home, err := os.UserHomeDir()
|
||||
cobra.CheckErr(err)
|
||||
|
||||
// Search config in home directory with name ".bottin.agendas" (without extension).
|
||||
viper.AddConfigPath(home)
|
||||
viper.SetConfigType("yaml")
|
||||
viper.SetConfigName(".bottin.agendas")
|
||||
}
|
||||
|
||||
// Read in env, will find matching viper bindings in flag.go [BindFlags]
|
||||
viper.SetEnvPrefix("AGENDAS")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
viper.AutomaticEnv() // read in environment variables that match
|
||||
|
||||
// If a config file is found, read it in.
|
||||
if err := viper.ReadInConfig(); err == nil {
|
||||
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
114
db.go
Normal file
114
db.go
Normal file
|
@ -0,0 +1,114 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.agecem.com/bottin/agendas/queries"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type DBClient struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func (d DBClient) Ping(ctx context.Context) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
if d.Pool == nil {
|
||||
return fmt.Errorf("db: cannot check health using nil connection pool")
|
||||
}
|
||||
|
||||
return d.Pool.Ping(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBClient) Init(ctx context.Context) error {
|
||||
//TODO check context is not closed
|
||||
//TODO check *DB is not nil
|
||||
log.Println("warning: DBClient [Init] not properly checked")
|
||||
// Init
|
||||
if _, err := d.Pool.Exec(ctx, queries.SQLSchema()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d DBClient) CreateTransaction(ctx context.Context, transaction Transaction) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
if d.Pool == nil {
|
||||
return fmt.Errorf("db: cannot check health using nil connection pool")
|
||||
}
|
||||
|
||||
// CreateTransaction
|
||||
commandTag, err := d.Pool.Exec(ctx, `
|
||||
INSERT INTO "transactions" (
|
||||
membre_id,
|
||||
is_perpetual
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2
|
||||
);
|
||||
`, transaction.MembreID, transaction.IsPerpetual)
|
||||
if err != nil {
|
||||
if err.Error() == `ERROR: duplicate key value violates unique constraint "transactions_pkey" (SQLSTATE 23505)` {
|
||||
return fmt.Errorf("Membre '%s' a déjà reçu son agenda %s.",
|
||||
transaction.MembreID,
|
||||
func() string {
|
||||
if transaction.IsPerpetual {
|
||||
return "perpétuel"
|
||||
} else {
|
||||
return "non-perpétuel"
|
||||
}
|
||||
}(),
|
||||
)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if commandTag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("No rows inserted")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBClient) ReadTransaction(ctx context.Context, membreID string, isPerpetual bool) (Transaction, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Transaction{}, ctx.Err()
|
||||
default:
|
||||
if d.Pool == nil {
|
||||
return Transaction{}, fmt.Errorf("db: cannot check health using nil connection pool")
|
||||
}
|
||||
|
||||
//TODO ReadTransaction
|
||||
return Transaction{}, fmt.Errorf("db: ReadTransaction not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBClient) CountTransactions(ctx context.Context) (membresSansAgenda, membresAvecPerpetuel, membresAvecNonPerpetuel, membresAvecTout int, err error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
err = ctx.Err()
|
||||
return
|
||||
default:
|
||||
if d.Pool == nil {
|
||||
err = fmt.Errorf("db: cannot check health using nil connection pool")
|
||||
return
|
||||
}
|
||||
|
||||
//TODO CountTransactions
|
||||
err = fmt.Errorf("db: CountTransactions not implemented")
|
||||
return
|
||||
}
|
||||
}
|
9
entity.go
Normal file
9
entity.go
Normal file
|
@ -0,0 +1,9 @@
|
|||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
type Transaction struct {
|
||||
MembreID string `db:"membre_id"`
|
||||
GivenAt *time.Time `db:"given_at"`
|
||||
IsPerpetual bool `db:"is_perpetual"`
|
||||
}
|
51
flag.go
Normal file
51
flag.go
Normal file
|
@ -0,0 +1,51 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const (
|
||||
configPort = "Port"
|
||||
defaultPort = 3333
|
||||
flagPort = "port"
|
||||
|
||||
configTLSEnabled = "TLS.Enabled"
|
||||
defaultTLSEnabled = true
|
||||
flagTLSEnabled = "tls-enabled"
|
||||
|
||||
configTLSCert = "TLS.Cert"
|
||||
defaultTLSCert = ""
|
||||
flagTLSCert = "tls-cert"
|
||||
|
||||
configTLSKey = "TLS.Key"
|
||||
defaultTLSKey = ""
|
||||
flagTLSKey = "tls-key"
|
||||
)
|
||||
|
||||
// BindClientFlags declares client-related flags and config options in the specified *pflag.FlagSet
|
||||
func BindFlags(set *pflag.FlagSet) error {
|
||||
// Credentials -> seulement par config
|
||||
|
||||
set.Int(flagPort, defaultPort, "User interface port")
|
||||
if err := viper.BindPFlag(configPort, set.Lookup(flagPort)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
set.Bool(flagTLSEnabled, defaultTLSEnabled, "User interface TLS state")
|
||||
if err := viper.BindPFlag(configTLSEnabled, set.Lookup(flagTLSEnabled)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
set.String(flagTLSKey, defaultTLSKey, "User interface TLS private key (or path to file)")
|
||||
if err := viper.BindPFlag(configTLSKey, set.Lookup(flagTLSKey)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
set.String(flagTLSCert, defaultTLSCert, "User interface TLS certificate (or path to file)")
|
||||
if err := viper.BindPFlag(configTLSCert, set.Lookup(flagTLSCert)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
9
go.mod
9
go.mod
|
@ -5,17 +5,20 @@ go 1.23.4
|
|||
require (
|
||||
codeberg.org/vlbeaudoin/voki/v3 v3.0.1
|
||||
git.agecem.com/bottin/bottin/v10 v10.4.1
|
||||
github.com/jackc/pgx/v5 v5.7.1
|
||||
github.com/labstack/echo/v4 v4.13.3
|
||||
golang.org/x/term v0.27.0
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/spf13/viper v1.19.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.1 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
|
@ -28,8 +31,6 @@ require (
|
|||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/viper v1.19.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
|
|
8
go.sum
8
go.sum
|
@ -2,6 +2,7 @@ codeberg.org/vlbeaudoin/voki/v3 v3.0.1 h1:pFjd/ZKsu4eOzRJYViE9F1S3RglSkAuIiqCo9I
|
|||
codeberg.org/vlbeaudoin/voki/v3 v3.0.1/go.mod h1:+6LMXosAu2ijNKV04sMwkeujpH+cghZU1fydqj2y95g=
|
||||
git.agecem.com/bottin/bottin/v10 v10.4.1 h1:mRZfqnLhGN9Qb+iQvfOvZM5pxXqSlkiXqIMX59zPAS8=
|
||||
git.agecem.com/bottin/bottin/v10 v10.4.1/go.mod h1:KTwlqY5XdVi9F7cpwy3hxYN1DQm+74tBv7Wc9rfKXuM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
|
@ -16,6 +17,8 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
|||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
|
@ -48,6 +51,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
|
@ -58,6 +62,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
|||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||
|
@ -96,8 +102,6 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
|
|
97
handler.go
97
handler.go
|
@ -11,14 +11,16 @@ import (
|
|||
)
|
||||
|
||||
// Handling
|
||||
func UIIndex(ctx context.Context, bottinClient *bottin.APIClient) echo.HandlerFunc {
|
||||
func UIIndex(ctx context.Context, bottinClient *bottin.APIClient, dbClient *DBClient) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
type data struct {
|
||||
Error string
|
||||
BottinHealthResponse bottin.ReadHealthResponse
|
||||
IsDBUp bool
|
||||
Result string
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "index", func() (d data) {
|
||||
return c.Render(http.StatusOK, "app", func() (d data) {
|
||||
if err := func() error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
@ -36,6 +38,16 @@ func UIIndex(ctx context.Context, bottinClient *bottin.APIClient) echo.HandlerFu
|
|||
}
|
||||
d.BottinHealthResponse = bottinHealthResponse
|
||||
|
||||
// Check db health
|
||||
if dbClient == nil {
|
||||
return fmt.Errorf("Impossible de contacter la base de données, le client DB est nil")
|
||||
}
|
||||
|
||||
if err := dbClient.Ping(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
d.IsDBUp = true
|
||||
|
||||
// No errors
|
||||
return nil
|
||||
}
|
||||
|
@ -51,40 +63,81 @@ func UIIndex(ctx context.Context, bottinClient *bottin.APIClient) echo.HandlerFu
|
|||
}
|
||||
}
|
||||
|
||||
func UIReadMembre(ctx context.Context, bottinClient *bottin.APIClient) echo.HandlerFunc {
|
||||
/*
|
||||
UICreateTransaction gère la création des transactions
|
||||
|
||||
TODO:
|
||||
- Fully implement
|
||||
- Check context is not closed
|
||||
*/
|
||||
func UICreateTransaction(ctx context.Context, cfg Config, bottinClient *bottin.APIClient, dbClient *DBClient) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
type data struct {
|
||||
Error string
|
||||
BottinMembreResponse bottin.ReadMembreResponse
|
||||
Result string
|
||||
//BottinHealth bottin.ReadHealthResponse
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "index", func() (d data) {
|
||||
return c.Render(http.StatusOK, "app", func() (d data) {
|
||||
if err := func() error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("Impossible de contacter le serveur: %s", ctx.Err())
|
||||
default:
|
||||
// Check client
|
||||
if bottinClient == nil {
|
||||
return fmt.Errorf("Impossible de contacter le serveur, le client API est nil")
|
||||
return fmt.Errorf("Cannot operate on nil *bottin.APIClient")
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
// Check membre
|
||||
d.BottinMembreResponse, err = bottinClient.ReadMembre(ctx, c.QueryParam("m"))
|
||||
/*
|
||||
bottinReadHealthResponse, err := bottinClient.ReadHealth(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// No errors
|
||||
return nil
|
||||
}
|
||||
}(); err != nil {
|
||||
// Send error to user
|
||||
d.Error = err.Error()
|
||||
d.BottinHealth = bottinReadHealthResponse
|
||||
*/
|
||||
|
||||
// Log error
|
||||
isPerpetual := c.FormValue("is_perpetual") == "on"
|
||||
membreID := c.FormValue("membre_id")
|
||||
|
||||
if membreID == "" {
|
||||
return fmt.Errorf("Aucun numéro étudiant sélectionné. Assurez-vous de cliquer sur la case 'Numéro étudiant:' avant de scanner.")
|
||||
}
|
||||
|
||||
//TODO check if membre already received before checking bottin
|
||||
|
||||
// check if membre exists in bottin
|
||||
membreResponse, err := bottinClient.ReadMembre(ctx, membreID)
|
||||
if err != nil {
|
||||
if err.Error() == "400 no rows in result set" {
|
||||
return fmt.Errorf("Numéro étudiant introuvable %s", membreID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if membreResponse.Data.Membre.ID != membreID {
|
||||
return fmt.Errorf("Bottin a retourné '%s' en demandant '%s'", membreID, membreResponse.Data.Membre.ID)
|
||||
}
|
||||
|
||||
// dbclient.CreateTransaction
|
||||
if err := dbClient.CreateTransaction(ctx, Transaction{
|
||||
MembreID: membreID,
|
||||
IsPerpetual: isPerpetual,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Prepare result message
|
||||
d.Result = fmt.Sprintf("Membre %s peut recevoir son agenda %s",
|
||||
membreID,
|
||||
func() string {
|
||||
if isPerpetual {
|
||||
return "perpétuel"
|
||||
} else {
|
||||
return "non-perpétuel"
|
||||
}
|
||||
}(),
|
||||
)
|
||||
|
||||
return nil
|
||||
}(); err != nil {
|
||||
d.Error = err.Error()
|
||||
log.Println("err:", d.Error)
|
||||
}
|
||||
return
|
||||
|
|
61
main.go
61
main.go
|
@ -3,37 +3,17 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"codeberg.org/vlbeaudoin/voki/v3"
|
||||
"git.agecem.com/bottin/bottin/v10/pkg/bottin"
|
||||
"golang.org/x/term"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Entry
|
||||
|
||||
func init() {}
|
||||
|
||||
func main() {
|
||||
cfg := Config{}
|
||||
cfg.Bottin.Host = "api.bottin.agecem.com"
|
||||
cfg.Bottin.Port = 443
|
||||
cfg.Bottin.TLS.Enabled = true
|
||||
|
||||
ctx := context.TODO()
|
||||
|
||||
fmt.Println("bottin password (no echo): ")
|
||||
password, err := term.ReadPassword(0)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cfg.Bottin.Key = string(password)
|
||||
|
||||
if err := run(ctx, cfg); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// Start commandline parsing then call `run`
|
||||
Execute()
|
||||
}
|
||||
|
||||
func run(ctx context.Context, cfg Config) error {
|
||||
|
@ -41,23 +21,38 @@ func run(ctx context.Context, cfg Config) error {
|
|||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
var protocol string
|
||||
|
||||
if cfg.Bottin.TLS.Enabled {
|
||||
protocol = "https"
|
||||
} else {
|
||||
protocol = "http"
|
||||
}
|
||||
|
||||
bottinClient := bottin.APIClient{Caller: voki.New(
|
||||
http.DefaultClient,
|
||||
cfg.Bottin.Host,
|
||||
cfg.Bottin.Key,
|
||||
cfg.Bottin.Port,
|
||||
protocol,
|
||||
func() string {
|
||||
if cfg.Bottin.TLS.Enabled {
|
||||
return "https"
|
||||
} else {
|
||||
return "http"
|
||||
}
|
||||
}(),
|
||||
)}
|
||||
|
||||
if err := RunServer(ctx, &bottinClient); err != nil && err != http.ErrServerClosed {
|
||||
// connect to db
|
||||
dbPool, err := pgxpool.New(ctx,
|
||||
fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
|
||||
cfg.DB.Username,
|
||||
cfg.DB.Password,
|
||||
cfg.DB.Host,
|
||||
cfg.DB.Port,
|
||||
cfg.DB.Database,
|
||||
cfg.DB.SSLMode,
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dbPool.Close()
|
||||
|
||||
dbClient := DBClient{Pool: dbPool}
|
||||
|
||||
if err := RunServer(ctx, cfg, &bottinClient, &dbClient); err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
10
queries/queries.go
Normal file
10
queries/queries.go
Normal file
|
@ -0,0 +1,10 @@
|
|||
package queries
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed schema.sql
|
||||
var sqlSchema string
|
||||
|
||||
func SQLSchema() string {
|
||||
return sqlSchema
|
||||
}
|
7
queries/schema.sql
Normal file
7
queries/schema.sql
Normal file
|
@ -0,0 +1,7 @@
|
|||
-- Schema
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
given_at TIMESTAMP DEFAULT current_timestamp,
|
||||
membre_id VARCHAR(7) NOT NULL CHECK (length(membre_id) > 0),
|
||||
is_perpetual BOOLEAN NOT NULL,
|
||||
PRIMARY KEY (membre_id, is_perpetual)
|
||||
);
|
54
server.go
54
server.go
|
@ -2,14 +2,17 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.agecem.com/bottin/agendas/ui"
|
||||
"git.agecem.com/bottin/bottin/v10/pkg/bottin"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
func RunServer(ctx context.Context, bottinClient *bottin.APIClient) error {
|
||||
func RunServer(ctx context.Context, cfg Config, bottinClient *bottin.APIClient, dbClient *DBClient) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
|
@ -18,16 +21,57 @@ func RunServer(ctx context.Context, bottinClient *bottin.APIClient) error {
|
|||
return fmt.Errorf("nil bottin client")
|
||||
}
|
||||
|
||||
if dbClient == nil {
|
||||
return fmt.Errorf("nil dbClient")
|
||||
}
|
||||
|
||||
if err := dbClient.Init(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
|
||||
e.Renderer = NewRenderer()
|
||||
r := ui.NewRenderer()
|
||||
|
||||
if r == nil {
|
||||
return fmt.Errorf("nil renderer")
|
||||
}
|
||||
|
||||
e.Renderer = r
|
||||
|
||||
e.Pre(middleware.AddTrailingSlash())
|
||||
|
||||
e.GET("/", UIIndex(ctx, bottinClient))
|
||||
// basic auth
|
||||
if len(cfg.Credentials) == 0 {
|
||||
return fmt.Errorf("UI requires at least one credential (config key `Credentials` of type map[string]string)")
|
||||
}
|
||||
|
||||
e.GET("/membre/", UIReadMembre(ctx, bottinClient))
|
||||
e.Pre(middleware.BasicAuth(
|
||||
func(username, password string, c echo.Context) (bool, error) {
|
||||
for validUser, validPass := range cfg.Credentials {
|
||||
userOK := subtle.ConstantTimeCompare([]byte(username), []byte(validUser)) == 1
|
||||
passOK := subtle.ConstantTimeCompare([]byte(password), []byte(validPass)) == 1
|
||||
if userOK && passOK {
|
||||
// log successful basic auths username
|
||||
log.Println("login ok for user", username)
|
||||
|
||||
return e.Start(":3333")
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}),
|
||||
)
|
||||
|
||||
e.GET("/", UIIndex(ctx, bottinClient, dbClient))
|
||||
//e.GET("/transaction/", UIReadTransaction
|
||||
e.POST("/transaction/", UICreateTransaction(ctx, cfg, bottinClient, dbClient))
|
||||
|
||||
address := fmt.Sprintf(":%d", cfg.Port)
|
||||
|
||||
if cfg.TLS.Enabled {
|
||||
return e.StartTLS(address, cfg.TLS.Cert, cfg.TLS.Key)
|
||||
} else {
|
||||
return e.Start(address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
121
ui/app.html
Normal file
121
ui/app.html
Normal file
|
@ -0,0 +1,121 @@
|
|||
{{ define "app" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>
|
||||
AGECEM | Agenda
|
||||
</title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
/* Center the form on the page */
|
||||
margin: 0 auto;
|
||||
width: 400px;
|
||||
/* Form outline */
|
||||
padding: 1em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 1em;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
form li + li {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
label {
|
||||
/* Uniform size & alignment */
|
||||
display: inline-block;
|
||||
width: 90px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
/* To make sure that all text fields have the same font settings
|
||||
By default, textareas have a monospace font */
|
||||
font: 1em sans-serif;
|
||||
|
||||
/* Uniform text field size */
|
||||
width: 300px;
|
||||
box-sizing: border-box;
|
||||
|
||||
/* Match form field borders */
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
/* Additional highlight for focused elements */
|
||||
border-color: #000;
|
||||
}
|
||||
|
||||
textarea {
|
||||
/* Align multiline text fields with their labels */
|
||||
vertical-align: top;
|
||||
|
||||
/* Provide space to type some text */
|
||||
height: 5em;
|
||||
}
|
||||
|
||||
.button {
|
||||
/* Align buttons with the text fields */
|
||||
padding-left: 90px; /* same size as the label elements */
|
||||
}
|
||||
|
||||
button {
|
||||
/* This extra margin represent roughly the same space as the space
|
||||
between the labels and their text fields */
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>
|
||||
Distribution d'agendas aux membres de l'AGECEM
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
1) Si l'agenda demandé est perpétuel, cochez 'Perpétuel?:'<br>
|
||||
<br>
|
||||
2) Sélectionnez le champs 'Numéro étudiant:'<br>
|
||||
<br>
|
||||
3) Scannez la carte étudiante d'unE membre<br>
|
||||
-ou-<br>
|
||||
Entrez manuellement le code à 7 chiffres<br>
|
||||
<br>
|
||||
4) Si aucune erreur ne survient, la personne est libre de partir avec son agenda
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<form action="/transaction/" method="post">
|
||||
<ul>
|
||||
<li>
|
||||
<label for="is_perpetual">Perpétuel?:</label>
|
||||
<input type="checkbox" id="is_perpetual" name="is_perpetual"/>
|
||||
</li>
|
||||
<li>
|
||||
<label for="membre_id">Numéro étudiant:</label>
|
||||
<input type="text" id="membre_id" name="membre_id" autofocus/>
|
||||
</li>
|
||||
<li class="button">
|
||||
<button type="submit">Scan</button>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
{{ if .Error }}<p class="result">👎 {{ .Error }}</p>{{ end }}
|
||||
{{ if .Result }}<p class="result">👍 {{ .Result }}</p>{{ end }}
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{{ end }}
|
|
@ -1,10 +1,8 @@
|
|||
package ui
|
||||
|
||||
import "embed"
|
||||
import (
|
||||
"embed"
|
||||
)
|
||||
|
||||
//go:embed *.html
|
||||
var htmlFS embed.FS
|
||||
|
||||
func HTMLFS() embed.FS {
|
||||
return htmlFS
|
||||
}
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
{{ define "index" }}
|
||||
hello world
|
||||
{{ . }}
|
||||
{{ end }}
|
|
@ -1,10 +1,9 @@
|
|||
package main
|
||||
package ui
|
||||
|
||||
import (
|
||||
"io"
|
||||
"text/template"
|
||||
|
||||
"git.agecem.com/bottin/agendas/ui"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
|
@ -18,6 +17,6 @@ func (t *Renderer) Render(w io.Writer, name string, data any, c echo.Context) er
|
|||
|
||||
func NewRenderer() *Renderer {
|
||||
return &Renderer{
|
||||
templates: template.Must(template.ParseFS(ui.HTMLFS(), "*html")),
|
||||
templates: template.Must(template.ParseFS(htmlFS, "*html")),
|
||||
}
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
{{ define "test" }}
|
||||
{{ . }}
|
||||
{{ end }}
|
Loading…
Reference in a new issue