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
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
@ -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"
|
||||
"git.agecem.com/bottin/bottin/v10/pkg/bottin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
|
@ -57,7 +58,16 @@ func run(ctx context.Context, cfg Config) error {
|
|||
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
|
||||
}
|
||||
|
||||
|
|
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"
|
||||
"fmt"
|
||||
|
||||
"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,15 +19,24 @@ func RunServer(ctx context.Context, bottinClient *bottin.APIClient) error {
|
|||
return fmt.Errorf("nil bottin client")
|
||||
}
|
||||
|
||||
if dbClient == nil {
|
||||
return fmt.Errorf("nil dbClient")
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
|
||||
e.Renderer = NewRenderer()
|
||||
e.Renderer = ui.NewRenderer()
|
||||
|
||||
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")
|
||||
}
|
||||
|
|
22
ui/embed.go
22
ui/embed.go
|
@ -1,6 +1,12 @@
|
|||
package ui
|
||||
|
||||
import "embed"
|
||||
import (
|
||||
"embed"
|
||||
"io"
|
||||
"text/template"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
//go:embed *.html
|
||||
var htmlFS embed.FS
|
||||
|
@ -8,3 +14,17 @@ var htmlFS embed.FS
|
|||
func HTMLFS() embed.FS {
|
||||
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" }}
|
||||
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 }}
|
||||
|
|
Loading…
Reference in a new issue