Produit minimal viable #1
10 changed files with 319 additions and 32 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
.env
|
15
compose.yaml
Normal file
15
compose.yaml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
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/'
|
||||||
|
volumes:
|
||||||
|
db-data:
|
53
db.go
Normal file
53
db.go
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"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
|
||||||
|
//TODO Init
|
||||||
|
return fmt.Errorf("db: Init not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DBClient) CreateTransaction(ctx context.Context, transaction Transaction) error {
|
||||||
|
//TODO check context is not closed
|
||||||
|
//TODO check *DB is not nil
|
||||||
|
//TODO CreateTransaction
|
||||||
|
return fmt.Errorf("db: CreateTransaction not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DBClient) ReadTransaction(ctx context.Context, membreID string, isPerpetual bool) error {
|
||||||
|
//TODO check context is not closed
|
||||||
|
//TODO check *DB is not nil
|
||||||
|
//TODO ReadTransaction
|
||||||
|
return fmt.Errorf("db: ReadTransaction not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DBClient) CountTransactions(ctx context.Context) (membresSansAgenda, membresAvecPerpetuel, membresAvecNonPerpetuel, membresAvecTout int, err error) {
|
||||||
|
//TODO check context is not closed
|
||||||
|
//TODO check *DB is not nil
|
||||||
|
//TODO CountTransactions
|
||||||
|
return 0, 0, 0, 0, fmt.Errorf("db: CountTransactions not implemented")
|
||||||
|
}
|
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"`
|
||||||
|
}
|
76
handler.go
76
handler.go
|
@ -11,11 +11,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handling
|
// 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 {
|
return func(c echo.Context) error {
|
||||||
type data struct {
|
type data struct {
|
||||||
Error string
|
Error string
|
||||||
BottinHealthResponse bottin.ReadHealthResponse
|
BottinHealthResponse bottin.ReadHealthResponse
|
||||||
|
IsDBUp bool
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Render(http.StatusOK, "index", func() (d data) {
|
return c.Render(http.StatusOK, "index", func() (d data) {
|
||||||
|
@ -36,6 +37,16 @@ func UIIndex(ctx context.Context, bottinClient *bottin.APIClient) echo.HandlerFu
|
||||||
}
|
}
|
||||||
d.BottinHealthResponse = bottinHealthResponse
|
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
|
// No errors
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -91,3 +102,66 @@ func UIReadMembre(ctx context.Context, bottinClient *bottin.APIClient) echo.Hand
|
||||||
}())
|
}())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
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 {
|
||||||
|
BottinHealth bottin.ReadHealthResponse
|
||||||
|
Error string
|
||||||
|
Result string
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Render(http.StatusOK, "index", func() (d data) {
|
||||||
|
if err := func() error {
|
||||||
|
if bottinClient == nil {
|
||||||
|
return fmt.Errorf("Cannot operate on nil *bottin.APIClient")
|
||||||
|
}
|
||||||
|
|
||||||
|
bottinReadHealthResponse, err := bottinClient.ReadHealth(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.BottinHealth = bottinReadHealthResponse
|
||||||
|
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// dbclient.CreateTransaction
|
||||||
|
if err := dbClient.CreateTransaction(ctx, Transaction{
|
||||||
|
MembreID: membreID,
|
||||||
|
IsPerpetual: isPerpetual,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare result message
|
||||||
|
var typeAgenda string
|
||||||
|
if isPerpetual {
|
||||||
|
typeAgenda = "perpétuel"
|
||||||
|
} else {
|
||||||
|
typeAgenda = "non-perpétuel"
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Result = fmt.Sprintf("👍 Membre %s peut recevoir son agenda %s", membreID, typeAgenda)
|
||||||
|
|
||||||
|
return fmt.Errorf("UIIndexPOST not fully implemented")
|
||||||
|
}(); err != nil {
|
||||||
|
d.Error = err.Error()
|
||||||
|
log.Println("err:", d.Error)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
12
main.go
12
main.go
|
@ -8,6 +8,7 @@ import (
|
||||||
|
|
||||||
"codeberg.org/vlbeaudoin/voki/v3"
|
"codeberg.org/vlbeaudoin/voki/v3"
|
||||||
"git.agecem.com/bottin/bottin/v10/pkg/bottin"
|
"git.agecem.com/bottin/bottin/v10/pkg/bottin"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -57,7 +58,16 @@ func run(ctx context.Context, cfg Config) error {
|
||||||
protocol,
|
protocol,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
if err := RunServer(ctx, &bottinClient); err != nil && err != http.ErrServerClosed {
|
// connect to db
|
||||||
|
dbPool, err := pgxpool.New(ctx, "postgres://agendas:agendas@localhost:5432/agendas")
|
||||||
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
23
render.go
23
render.go
|
@ -1,23 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"text/template"
|
|
||||||
|
|
||||||
"git.agecem.com/bottin/agendas/ui"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Renderer struct {
|
|
||||||
templates *template.Template
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Renderer) Render(w io.Writer, name string, data any, c echo.Context) error {
|
|
||||||
return t.templates.ExecuteTemplate(w, name, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRenderer() *Renderer {
|
|
||||||
return &Renderer{
|
|
||||||
templates: template.Must(template.ParseFS(ui.HTMLFS(), "*html")),
|
|
||||||
}
|
|
||||||
}
|
|
18
server.go
18
server.go
|
@ -4,12 +4,13 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"git.agecem.com/bottin/agendas/ui"
|
||||||
"git.agecem.com/bottin/bottin/v10/pkg/bottin"
|
"git.agecem.com/bottin/bottin/v10/pkg/bottin"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"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 {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
|
@ -18,15 +19,24 @@ func RunServer(ctx context.Context, bottinClient *bottin.APIClient) error {
|
||||||
return fmt.Errorf("nil bottin client")
|
return fmt.Errorf("nil bottin client")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if dbClient == nil {
|
||||||
|
return fmt.Errorf("nil dbClient")
|
||||||
|
}
|
||||||
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|
||||||
e.Renderer = NewRenderer()
|
e.Renderer = ui.NewRenderer()
|
||||||
|
|
||||||
e.Pre(middleware.AddTrailingSlash())
|
e.Pre(middleware.AddTrailingSlash())
|
||||||
|
|
||||||
e.GET("/", UIIndex(ctx, bottinClient))
|
//basicauth TODO
|
||||||
|
//e.Use(
|
||||||
|
|
||||||
e.GET("/membre/", UIReadMembre(ctx, bottinClient))
|
e.GET("/", UIIndex(ctx, bottinClient, dbClient))
|
||||||
|
//e.GET("/transaction/", UIReadTransaction
|
||||||
|
e.POST("/transaction/", UICreateTransaction(ctx, cfg, bottinClient, dbClient))
|
||||||
|
|
||||||
|
//e.GET("/membre/", UIReadMembre(ctx, bottinClient))
|
||||||
|
|
||||||
return e.Start(":3333")
|
return e.Start(":3333")
|
||||||
}
|
}
|
||||||
|
|
22
ui/embed.go
22
ui/embed.go
|
@ -1,6 +1,12 @@
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import "embed"
|
import (
|
||||||
|
"embed"
|
||||||
|
"io"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
//go:embed *.html
|
//go:embed *.html
|
||||||
var htmlFS embed.FS
|
var htmlFS embed.FS
|
||||||
|
@ -8,3 +14,17 @@ var htmlFS embed.FS
|
||||||
func HTMLFS() embed.FS {
|
func HTMLFS() embed.FS {
|
||||||
return htmlFS
|
return htmlFS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Renderer struct {
|
||||||
|
templates *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Renderer) Render(w io.Writer, name string, data any, c echo.Context) error {
|
||||||
|
return t.templates.ExecuteTemplate(w, name, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRenderer() *Renderer {
|
||||||
|
return &Renderer{
|
||||||
|
templates: template.Must(template.ParseFS(HTMLFS(), "*html")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
122
ui/index.html
122
ui/index.html
|
@ -1,4 +1,122 @@
|
||||||
{{ define "index" }}
|
{{ define "index" }}
|
||||||
hello world
|
<!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>
|
||||||
|
|
||||||
|
<!--<p class="result">{{ .Result }}</p>--!>
|
||||||
|
<!--<p>{{ .Error }}</p>--!>
|
||||||
|
<!--<details><summary>Détails de connexion au bottin</summary>{{ .BottinHealth }}</details>--!>
|
||||||
|
<p class="result">{{ . }}</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
Loading…
Reference in a new issue