Compare commits

...

20 Commits

Author SHA1 Message Date
b150801267 Use flask-login instead of client-side sessions for storing selected location and orders 2023-08-22 13:21:29 +02:00
28a58284a9 Add flask-login 2023-08-22 13:21:14 +02:00
d9114d21c7 Some doc stuff 2023-08-22 13:14:56 +02:00
2108ef2418 Add unitsLeft field to SearchResult type 2023-08-22 13:14:56 +02:00
a2d1d42362 Implement adding new inventory items 2023-08-22 13:14:56 +02:00
32bcb1b18a fixup! Store location name, group name and tax group description in order 2023-08-22 13:14:56 +02:00
1e4aadee06 Store location name, group name and tax group description in order
Also delete compiled entry JS and add a Makefile
2023-08-22 13:14:56 +02:00
a82e75999b Restyle calculator 2023-08-22 13:14:56 +02:00
7b95b69c7b Make it possible to delete orders from the cart 2023-08-22 13:14:56 +02:00
e0aef726bd Implement adding orders to the cart and viewing it 2023-08-22 13:14:56 +02:00
7707a9da6d Make entry for submittable 2023-08-22 13:14:56 +02:00
ca2e2e2e4a Refactor calculator into its own module 2023-08-22 13:14:56 +02:00
208cc54e53 Implement price calculator 2023-08-22 13:14:56 +02:00
f7dd572fe1 Add note about entry.js to readme 2023-08-22 13:14:56 +02:00
69dba202d0 Move around gross unit price input 2023-08-22 13:14:56 +02:00
3d6bd7bac5 Rewrite some of the grossUnitPrice stuff 2023-08-22 13:14:56 +02:00
0f9be13e31 Refactor fronted somewhat 2023-08-22 13:14:56 +02:00
be86248cce First draft of elm frontend
This will probably be scrapped or rewritten
2023-08-22 13:14:54 +02:00
01d060cf5f Remove old entry stuff 2023-08-22 13:14:15 +02:00
8c5b34073d Add note about unsafe client sessions 2023-08-21 16:05:28 +02:00
29 changed files with 916 additions and 237 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ jon/config.json
elm-stuff/
venv/
.venv/
jon/static/*.js

6
Makefile Normal file
View File

@ -0,0 +1,6 @@
.PHONY: frontend
frontend: jon/static/entry.js
jon/static/entry.js: $(shell find frontend -name '*.elm')
elm make --optimize frontend/Entry.elm --output $@

View File

@ -11,6 +11,19 @@ pip install -r requirements.txt
You should probably use a virtualenv for that.
I develop `jon` using Python 3.10 but it should work with older versions as well.
### Building Frontend JS
Most of jon works without JS but there are some features that require it.
The frontend code lives in `./frontend` and is written using Elm, a functional language that compiles to JS.
To compile the Elm code to `.js` files, first make sure that the Elm compiler is installed:
```
elm --version
# 0.19.1
```
Then run `make frontend`.
## Running
```
@ -27,11 +40,18 @@ ssh -nNTvL 5432:fsmi-db.fsmi.org:5432 fsmi-login.fsmi.uni-karlsruhe.de
## TODO
- [ ] Implement item and snack entry as Elm application
- [ ] Needs good documentation for maintainability
- [ ] Implement and document report generation
- [ ] How many days will the item last?
- [ ] How many do we need to last X months?
- [ ] etc.
- [ ] Make it print nicely
- [ ] Make it possible to edit entries
- [ ] Fix unsafe client-side sessions, either:
- [ ] Use `flask-session` for file-backed sessions
- [ ] Use `flask-login` with a single user stored in memory
- [ ] Improve project structure
- [ ] Use `flask.flash` for error messages
- [x] Implement item and snack entry as Elm application
- [x] Figure out/Add documentation about building `entry.js`
- [ ] Clean up the code a little and add some comments
- [ ] Needs good documentation for maintainability

28
elm.json Normal file
View File

@ -0,0 +1,28 @@
{
"type": "application",
"source-directories": [
"frontend"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"NoRedInk/elm-json-decode-pipeline": "1.0.1",
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/http": "2.0.0",
"elm/json": "1.1.3"
},
"indirect": {
"elm/bytes": "1.0.8",
"elm/file": "1.0.5",
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.3"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

115
frontend/Calculator.elm Normal file
View File

@ -0,0 +1,115 @@
module Calculator exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import NumberInput
import Select
type Tax = Net | Gross
ctShow ct = case ct of
Gross -> "Brutto"
Net -> "Netto"
type alias Model =
{ tax : Select.Model Tax
, bundlePrice : NumberInput.Model Float
, bundleSize : NumberInput.Model Int
}
init bundlePrice = Model
(Select.init ctShow ctShow Net [Net, Gross])
(NumberInput.fromFloat bundlePrice)
(NumberInput.fromInt 1)
getResult model taxGroup =
case (NumberInput.get model.bundlePrice, NumberInput.get model.bundleSize) of
(Just bundlePrice, Just bundleSize) ->
Just <| roundTo 2 <|
if Select.get model.tax == Gross then
(bundlePrice / toFloat bundleSize) / (1 + taxGroup.percentage)
else
bundlePrice / toFloat bundleSize
_ ->
Nothing
type Msg
= SetTax String
| SetBundlePrice String
| SetBundleSize String
update msg model = case msg of
SetTax key ->
{ model | tax = Select.update key model.tax }
SetBundlePrice str ->
{ model | bundlePrice = NumberInput.update str model.bundlePrice }
SetBundleSize str ->
{ model | bundleSize = NumberInput.update str model.bundleSize }
view model taxGroup =
let
mainPart =
[ text "("
, div [ class "form-input --inline" ]
[ label [ for "bundle-price" ] [ text "Gebindepreis" ]
, input
[ placeholder "Gebindepreis"
, value <| NumberInput.show model.bundlePrice
, onInput SetBundlePrice
, id "bundle-price"
, type_ "number"
, step "0.01"
]
[]
]
, text " ÷ "
, div [ class "form-input --inline" ]
[ label [ for "bundle-size" ] [ text "Gebindegröße" ]
, input
[ placeholder "Gebindegröße"
, value <| NumberInput.show model.bundleSize
, onInput SetBundleSize
, id "bundle-size"
, type_ "number"
, step "1"
]
[]
]
, text ") "
]
taxPart =
[ text " ÷ "
, div [ class "form-input --inline" ]
[ label [ for "tax" ] [ text "Steuer" ]
, input
[ value <| String.fromFloat <| 1 + taxGroup.percentage
, id "tax"
, type_ "number"
, step "0.01"
, disabled True
]
[]
]
]
resultPart =
[ text " = "
, text <| Maybe.withDefault "?" <| Maybe.map String.fromFloat <| getResult model taxGroup
]
in
fieldset []
[ legend [] [ text "Preisrechner" ]
, div [] <| List.concat <| List.filterMap identity
[ Just mainPart
, if Select.get model.tax == Gross then Just taxPart else Nothing
, Just resultPart
]
, div [ class "form-input" ]
[ label [ for "calculator-tax" ] [ text "Gebindepreis ist" ]
, Select.view [] SetTax model.tax
]
]
roundTo places x = toFloat (round <| x * 10 ^ places) / 10 ^ places

361
frontend/Entry.elm Normal file
View File

@ -0,0 +1,361 @@
module Entry exposing (main)
import Browser
import Http
import Json.Decode as D
import Json.Decode.Pipeline as P
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Calculator
import NumberInput
import Select
main = Browser.element
{ init = \globals ->
( Context globals <| ItemSearch { searchTerm = "", searchResults = [] }
, Cmd.none
)
, subscriptions = \_ -> Sub.none
, update = update
, view = view
}
-- Data types
type alias SearchResult =
{ barcode : String
, name : String
, netUnitPrice : Float
, bought : String
, salesUnits : Int
, available : Bool
, locationName : String
, locationId : Int
, groupName : String
, groupId : Int
, taxGroupId : Int
, unitsLeft : Int
}
searchResultDecoder =
D.succeed SearchResult
|> P.required "item_barcode" D.string
|> P.required "name" D.string
|> P.required "unit_price" D.float
|> P.required "bought" D.string
|> P.required "sales_units" D.int
|> P.required "available" D.bool
|> P.required "location_name" D.string
|> P.required "location_id" D.int
|> P.required "group_name" D.string
|> P.required "group_id" D.int
|> P.required "tax_group_id" D.int
|> P.required "units_left" D.int
type alias Location =
{ id : Int
, name : String
}
type alias Group =
{ id : Int
, name : String
}
type alias TaxGroup =
{ id : Int
, description : String
, percentage : Float
}
type alias NewItem =
{ barcode : String
, name : String
, salesUnits : Int
, group : Group
, location : Location
, netUnitPrice : Float
, taxGroup : TaxGroup
}
type alias Context =
{ globals : Globals
, state : State
}
type alias Globals =
{ locations : List Location
, groups : List Group
, taxGroups : List TaxGroup
}
type State
= ItemSearch
{ searchTerm : String
, searchResults : List SearchResult
}
| ItemEditor
{ barcode : String
, name : String
, salesUnits : NumberInput.Model Int
, calculator : Calculator.Model
, netUnitPrice : NumberInput.Model Float
, grossUnitPrice : NumberInput.Model Float
, group : Select.Model Group
, location : Select.Model Location
, taxGroup : Select.Model TaxGroup
}
type Msg
= SetSearchTerm String
| SubmitSearch
| ReceiveSearchResults (Result Http.Error (List SearchResult))
| GotoItemEditor SearchResult
| SetBarcode String
| SetName String
| SetSalesUnits String
| CalculatorMsg Calculator.Msg
| SetNetUnitPrice String
| SetGrossUnitPrice String
| SetGroup String
| SetLocation String
| SetTaxGroup String
-- Update logic: State machine etc.
update msg { globals, state } =
let
(state_, cmd) = updateState msg globals state
in
({ globals = globals, state = state_ }, cmd)
updateState msg globals state = case state of
ItemSearch model -> case msg of
SetSearchTerm searchTerm ->
(ItemSearch { model | searchTerm = searchTerm }, Cmd.none)
SubmitSearch ->
( state
, Http.get
{ url = "/entry/api/search-items?search-term=" ++ model.searchTerm
, expect = Http.expectJson ReceiveSearchResults <| D.list searchResultDecoder
}
)
ReceiveSearchResults (Ok searchResults) ->
(ItemSearch { model | searchResults = searchResults }, Cmd.none)
GotoItemEditor searchResult ->
case find (\tg -> tg.id == searchResult.taxGroupId) globals.taxGroups of
Nothing -> (state, Cmd.none)
Just taxGroup ->
( ItemEditor
{ barcode = searchResult.barcode
, name = searchResult.name
, calculator = Calculator.init searchResult.netUnitPrice
, netUnitPrice = NumberInput.fromFloat searchResult.netUnitPrice
, grossUnitPrice = NumberInput.fromFloat
(suggestedGrossPrice searchResult.netUnitPrice taxGroup.percentage)
, salesUnits = NumberInput.fromInt searchResult.salesUnits
, group = Select.init (.id >> String.fromInt) (.name) { id = searchResult.groupId, name = searchResult.groupName } globals.groups
, location = Select.init (.id >> String.fromInt) (.name) { id = searchResult.locationId, name = searchResult.locationName } globals.locations
, taxGroup = Select.init (.id >> String.fromInt) (.description) taxGroup globals.taxGroups
}
, Cmd.none
)
_ ->
(state, Cmd.none)
ItemEditor model -> case msg of
SetBarcode barcode ->
(ItemEditor { model | barcode = barcode }, Cmd.none)
SetName name ->
(ItemEditor { model | name = name }, Cmd.none)
SetSalesUnits str ->
(ItemEditor { model | salesUnits = NumberInput.update str model.salesUnits }, Cmd.none)
CalculatorMsg msg_ ->
(ItemEditor { model | calculator = Calculator.update msg_ model.calculator }, Cmd.none)
SetNetUnitPrice str ->
( ItemEditor { model | netUnitPrice = NumberInput.update str model.netUnitPrice }
, Cmd.none
)
SetGrossUnitPrice str ->
( ItemEditor { model | grossUnitPrice = NumberInput.update str model.grossUnitPrice }
, Cmd.none
)
SetGroup key ->
(ItemEditor { model | group = Select.update key model.group }, Cmd.none)
SetLocation key ->
(ItemEditor { model | location = Select.update key model.location }, Cmd.none)
SetTaxGroup key ->
( ItemEditor { model | taxGroup = Select.update key model.taxGroup }
, Cmd.none
)
_ ->
(state, Cmd.none)
suggestedGrossPrice netPrice percentage =
roundTo 2 <| netPrice * (1 + percentage) + 0.01
-- View stuff
view { globals, state } = case state of
ItemSearch model ->
fieldset []
[ legend [] [ text "Vorlage für Auftrag wählen" ]
, Html.form [ onSubmit SubmitSearch ]
[ div [ class "form-input" ]
[ label [ for "search-term", title "Barcode oder Name" ] [ text "Suchbegriff" ]
, input [ onInput SetSearchTerm, value model.searchTerm, id "search-term" ] []
]
, table [] <| searchResultHeaders :: List.map viewSearchResult model.searchResults
]
]
ItemEditor model ->
Html.form [ method "POST" ]
[ fieldset []
[ legend [] [ text "Neuer Inventareintrag" ]
, div [ class "form-input" ]
[ label [ for "barcode" ] [ text "Barcode" ]
, input [ onInput SetBarcode, value model.barcode, name "barcode", id "barcode" ] []
]
, div [ class "form-input" ]
[ label [ for "name" ] [ text "Name" ]
, input [ onInput SetName, value model.name, name "name", id "name" ] []
]
, div [ class "form-input" ]
[ label [ for "sales-units" ] [ text "Eingekauft" ]
, input [ onInput SetSalesUnits, value <| NumberInput.show model.salesUnits, name "sales-units", id "sales-units", type_ "number" ] []
]
, div [ class "form-input" ]
[ label [ for "group" ] [ text "Gruppe" ]
, Select.view [ name "group-id", id "group" ] SetGroup model.group
, input
[ type_ "hidden"
, name "group-name"
, value <| (Select.get model.group).name
]
[]
]
, div [ class "form-input" ]
[ label [ for "location" ] [ text "Raum" ]
, Select.view [ name "location-id", id "location" ] SetLocation model.location
, input
[ type_ "hidden"
, name "location-name"
, value <| (Select.get model.location).name
]
[]
]
, div [ class "form-input" ]
[ label [ for "tax-group" ] [ text "Steuergruppe" ]
, Select.view [ name "tax-group-id", id "tax-group" ] SetTaxGroup model.taxGroup
, input
[ type_ "hidden"
, name "tax-group-description"
, value <| (Select.get model.taxGroup).description
]
[]
]
, Html.map CalculatorMsg <| Calculator.view model.calculator (Select.get model.taxGroup)
, div [ class "form-input" ]
[ label [ for "net-unit-price" ] [ text "Einkaufspreis (Netto)" ]
, input
[ value <| NumberInput.show model.netUnitPrice
, onInput SetNetUnitPrice
, type_ "number"
, name "net-unit-price"
, id "net-unit-price"
, step "0.01"
]
[]
, viewSetCalculatedPriceButton model
]
, div [ class "form-input" ]
[ label [ for "gross-unit-price" ] [ text "Verkaufspreis (Brutto)" ]
, input
[ value <| NumberInput.show model.grossUnitPrice
, onInput SetGrossUnitPrice
, type_ "number"
, name "gross-unit-price"
, id "gross-unit-price"
, step "0.01"
]
[]
, viewSetSuggestedPriceButton model
]
, button [] [ text "Auftrag anlegen" ]
]
]
viewSetCalculatedPriceButton model =
case Calculator.getResult model.calculator (Select.get model.taxGroup) of
Nothing ->
button [ disabled True ] [ text "Auf ? setzen" ]
Just calculatedPrice ->
button
[ onClick <| SetNetUnitPrice <| String.fromFloat calculatedPrice
-- Prevent submitting the form
, type_ "button"
]
[ text <| "Auf " ++ String.fromFloat calculatedPrice ++ " setzen"
]
viewSetSuggestedPriceButton model =
case NumberInput.get model.netUnitPrice of
Nothing ->
button [ disabled True ] [ text "Auf ? setzen" ]
Just netUnitPrice ->
let
grossUnitPrice = suggestedGrossPrice netUnitPrice (Select.get model.taxGroup).percentage
in
button
[ onClick <| SetGrossUnitPrice <| String.fromFloat grossUnitPrice
-- Prevent submitting the form
, type_ "button"
]
[ text <| "Auf " ++ String.fromFloat grossUnitPrice ++ " setzen"
]
searchResultHeaders =
tr []
[ th [] [ text "Barcode" ]
, th [] [ text "Name" ]
, th [] [ text "Gruppe" ]
, th [] [ text "Stückpreis (Netto)" ]
, th [] [ text "Eingekauft" ]
, th [] [ text "Kaufdatum" ]
, th [] [ text "Raum" ]
, th [] [ text "Aktiv?" ]
, th [] []
]
viewSearchResult model =
tr []
[ td [] [ code [] [ text model.barcode ] ]
, td [] [ text model.name ]
, td [] [ text model.groupName ]
, td [] [ text <| String.fromFloat model.netUnitPrice ]
, td [] [ text <| String.fromInt model.salesUnits ]
, td [] [ text model.bought ]
, td [] [ text model.locationName ]
, td [] [ text <| showBool model.available ]
, td []
[ Html.form [ onSubmit <| GotoItemEditor model ]
[ button [] [ text "Als Vorlage verwenden" ]
]
]
]
calculateGarfieldPrice model =
NumberInput.get model.netUnitPrice |> Maybe.map (\netUnitPrice ->
roundTo 2 <| netUnitPrice * (1 + (Select.get model.taxGroup).percentage) + 0.01
)
roundTo places x = toFloat (round <| x * 10 ^ places) / 10 ^ places
showBool b = case b of
True -> ""
False -> ""
find pred xs = List.head <| List.filter pred xs

29
frontend/NumberInput.elm Normal file
View File

@ -0,0 +1,29 @@
module NumberInput exposing (..)
type alias Model a =
{ value : Maybe a
, original : String
, convert : String -> Maybe a
}
fromFloat : Float -> Model Float
fromFloat x = Model (Just x) (String.fromFloat x) String.toFloat
fromInt : Int -> Model Int
fromInt x = Model (Just x) (String.fromInt x) String.toInt
get : Model a -> Maybe a
get = .value
withDefault : a -> Model a -> a
withDefault d = Maybe.withDefault d << get
isValid : Model a -> Bool
isValid model = model.value /= Nothing
update : String -> Model a -> Model a
update str model =
{ model | value = model.convert str, original = str }
show : Model a -> String
show = .original

36
frontend/Select.elm Normal file
View File

@ -0,0 +1,36 @@
module Select exposing (..)
import Html exposing (Attribute, Html, option, select, text)
import Html.Attributes exposing (selected, value)
import Html.Events exposing (onInput)
type alias Model a =
{ identify : a -> String
, show : a -> String
, selected : a
, options : List a
}
init : (a -> String) -> (a -> String) -> a -> List a -> Model a
init = Model
update : String -> Model a -> Model a
update key model =
case find (\x -> key == model.identify x) model.options of
Nothing -> model
Just x -> { model | selected = x }
view : List (Attribute m) -> (String -> m) -> Model a -> Html m
view attributes msg model =
let
viewOption x =
option
[ selected <| model.identify model.selected == model.identify x, value <| model.identify x ]
[ text <| model.show x ]
in
select ([ onInput msg ] ++ attributes) <| List.map viewOption model.options
get : Model a -> a
get = .selected
find pred xs = List.head <| List.filter pred xs

View File

@ -22,12 +22,7 @@ def create_app():
app.config.from_file("config.json", load=json.load, silent=True)
db.init_app(app)
# This function denies every request until `auth.ACCESS_TOKEN`
# is passed using `?token=` to authenticate the session.
@app.before_request
def before_req_fun():
return auth.before_request()
auth.init_app(app)
@app.context_processor
def utility_processor():

View File

@ -1,7 +1,10 @@
import secrets
import string
from flask import Blueprint, request, redirect, render_template, session
from flask import Blueprint, request, redirect, render_template
from flask_login import current_user, login_user, logout_user, LoginManager
from typing import Any, Dict, List, Optional
bp = Blueprint("auth", __name__, url_prefix="/auth")
@ -15,27 +18,67 @@ ALLOWED_PATHS = [
]
def before_request():
"""
If the correct token query parameter is passed along with any request,
we mark this session authenticated by setting `session["authenticated"]`.
Unless the session is authenticated, all requests result in a 403 FORBIDDEN.
"""
if "token" in request.args:
if request.args["token"] == ACCESS_TOKEN:
session["authenticated"] = ()
# Reload the page without query parameters
return redirect(request.path)
# A poor man's replacement for memory-backed session solution.
# We keep exactly one User (and the corresponding UserData) in
# memory and use that to store session data.
class UserData:
location: Optional[Dict[str, Any]]
orders: List[Dict[str, Any]]
# Don't deny any paths in `ALLOWED_PATHS`
if request.path in ALLOWED_PATHS:
return
def __init__(self):
self.location = None
self.orders = []
if not "authenticated" in session:
return render_template("auth/denied.html"), 403
class User:
is_authenticated: bool
is_active: bool
is_anonymous: bool
data: UserData
def __init__(self):
self.is_authenticated = True
self.is_active = True
self.is_anonymous = False
self.data = UserData()
def get_id(self) -> str:
return ""
def init_app(app):
login_manager = LoginManager(app)
the_one_and_only_user = User()
@login_manager.user_loader
def load_user(user_id: str) -> User:
assert user_id == ""
return the_one_and_only_user
# This function denies every request until `auth.ACCESS_TOKEN`
# is passed using `?token=` to authenticate the user.
# We use this instead of @login_required because otherwise we'd have
# to add that annotation to all routes.
# See also: https://flask-login.readthedocs.io/en/latest/#flask_login.login_required
@app.before_request
def before_request():
if "token" in request.args:
if request.args["token"] == ACCESS_TOKEN:
login_user(the_one_and_only_user)
# Reload the page without query parameters
return redirect(request.path)
# Never deny any paths in `ALLOWED_PATHS`
if request.path in ALLOWED_PATHS:
return
if not current_user.is_authenticated:
return render_template("auth/denied.html"), 403
@bp.get("/logout")
def logout():
session.pop("authenticated", None)
logout_user()
return redirect("/")

View File

@ -7,8 +7,6 @@ from psycopg2.extras import RealDictCursor
def get_db():
if "db" not in g:
# TODO: Make this configurable and use a default that works
# on the pool computers.
g.db = psycopg2.connect(current_app.config["DB_CONNECTION_STRING"])
run_query_on(g.db, "add_views.sql", None)

View File

@ -11,6 +11,7 @@ SELECT
inventory_items.available,
inventory_items.item_group,
inventory_items.location,
inventory_items.tax_group,
inventory_item_groups.group_name,
COALESCE(b.sales::numeric, 0::numeric) - COALESCE(cancel.count::numeric, 0::numeric) AS sales,
inventory_items.sales_units::numeric - COALESCE(b.sales, 0::bigint)::numeric + COALESCE(c.delta, 0::numeric) + COALESCE(cancel.count::numeric, 0::numeric) AS units_left,

View File

@ -0,0 +1,26 @@
DO $$
DECLARE new_item_id integer;
DECLARE new_snack_id integer;
BEGIN
-- Create a new inventory line
INSERT INTO garfield.inventory_items
(item_barcode, name, item_group, location, tax_group, sales_units, unit_price)
VALUES
(%(barcode)s, %(name)s, %(group_id)s, %(location_id)s, %(tax_group_id)s, %(sales_units)s, %(net_unit_price)s)
RETURNING item_id INTO new_item_id;
-- Delete (i.e. mark as inactive) any old snacks with the same barcode in the given location
PERFORM garfield.snack_delete(snack_id)
FROM garfield.snacks
WHERE snack_barcode = %(barcode)s
AND location_id = %(location_id)s;
-- Create a new snack entry...
SELECT garfield.snack_create(%(name)s, %(barcode)s, %(gross_unit_price)s, %(tax_group_id)s, %(location_id)s)
INTO new_snack_id;
-- ... and map it to the new inventory line
PERFORM garfield.inventory_map_snack(new_snack_id, new_item_id);
END$$

View File

@ -1,6 +1,6 @@
SELECT
group_id,
group_name
group_id AS id,
group_name AS name
FROM garfield.inventory_item_groups
ORDER BY
group_name ASC

View File

@ -0,0 +1,4 @@
SELECT
location_id AS id,
location_name AS name
FROM garfield.locations

View File

@ -0,0 +1,9 @@
SELECT
tax_group_id AS id,
description,
tax_percentage :: float AS percentage,
tax_description AS description
FROM garfield.tax_groups,
LATERAL garfield.tax_find(tax_group_id, NOW() :: date) AS tax_id
LEFT JOIN garfield.taxes USING (tax_id)
WHERE active

17
jon/db/search_items.sql Normal file
View File

@ -0,0 +1,17 @@
SELECT
item_barcode,
name,
unit_price :: float,
TO_CHAR(bought, 'YYYY-MM-DD') AS bought,
sales_units,
available,
location_name,
location as location_id,
group_name,
item_group AS group_id,
tax_group AS tax_group_id,
units_left :: integer
FROM all_inventory_item_overview
WHERE (%(location_id)s IS NULL OR location = %(location_id)s)
AND (name ILIKE CONCAT('%%', %(search_term)s, '%%') OR item_barcode = %(search_term)s)
ORDER BY bought DESC

View File

@ -1,8 +1,5 @@
import datetime
import zoneinfo
from flask import Blueprint, redirect, render_template, request, session
from flask import Blueprint, flash, redirect, render_template, request
from flask_login import current_user
from . import db
@ -10,50 +7,117 @@ from . import db
bp = Blueprint("entry", __name__, url_prefix="/entry")
@bp.get("/")
@bp.route("/", methods=["GET", "POST"])
def index():
return render_template("entry/index.html")
return render_template(
"entry/index.html"
)
@bp.route("/edit-item-data", methods=["GET", "POST"])
def edit_item_data():
if "entry" not in session:
session["entry"] = dict()
@bp.post("/add-new-items")
def add_new_entries():
i_know_what_im_doing = "i-know-what-im-doing" in request.form
if not i_know_what_im_doing:
return "Du weißt nicht was du tust", 400
orders = current_user.data.orders
if not orders:
return "Keine Aufträge", 404
# I'm aware of execute_many and extras.execute_values but we don't need to
# optimize here (yet?). This way it's a bit easier to use anyways.
for order in orders:
with db.run_query("entry/add_item_and_snack_entry.sql", order) as cursor:
pass
db.get_db().commit()
# Reset the cart
current_user.data.orders = []
return redirect(request.referrer)
@bp.post("/delete-order")
def delete_order():
try:
order_index = int(request.form["order-index"])
except:
return "Incomplete or mistyped form", 400
del current_user.data.orders[order_index]
return redirect(request.referrer)
@bp.route("/new-order", methods=["GET", "POST"])
def new_order():
if request.method == "POST":
session["entry"] = {
"item_bought": datetime.datetime.strptime(request.form.get("item_bought"), "%Y-%m-%d"),
"item_barcode": request.form.get("item_barcode"),
"item_name": request.form.get("item_name"),
"item_group_id": int(request.form.get("item_group")),
"item_net_unit_price": float(request.form.get("item_net_unit_price")),
"item_tax_group_id": int(request.form.get("item_tax_group")),
"item_amount": int(request.form.get("item_amount")),
"item_location_id": int(request.form.get("item_location"))
}
try:
barcode = request.form["barcode"]
name = request.form["name"]
sales_units = int(request.form["sales-units"])
group_id = int(request.form["group-id"])
# group_name, location_name and tax_group_description are not
# necessarily needed here but storing them makes it easier to
# render the list of orders.
group_name = request.form["group-name"]
location_id = int(request.form["location-id"])
location_name = request.form["location-name"]
tax_group_id = int(request.form["tax-group-id"])
tax_group_description = request.form["tax-group-description"]
net_unit_price = float(request.form["net-unit-price"])
gross_unit_price = float(request.form["gross-unit-price"])
except:
return f"Incomplete or mistyped form", 400
current_user.data.orders.append({
"barcode": barcode,
"name": name,
"sales_units": sales_units,
"group_id": group_id,
"group_name": group_name,
"location_id": location_id,
"location_name": location_name,
"tax_group_id": tax_group_id,
"tax_group_description": tax_group_description,
"net_unit_price": net_unit_price,
"gross_unit_price": gross_unit_price
})
return redirect("/entry")
return redirect("/entry/select-snack-entry")
with db.run_query("entry/get_groups.sql") as cursor:
groups = cursor.fetchall()
groups = db.run_query("get_groups.sql").fetchall()
locations = db.run_query("get_locations.sql").fetchall()
with db.run_query("entry/get_locations.sql") as cursor:
locations = cursor.fetchall()
return render_template("entry/edit-item-data.html", **{
"groups": groups,
"locations": locations,
"entry": session["entry"]
})
with db.run_query("entry/get_tax_groups.sql") as cursor:
tax_groups = cursor.fetchall()
return render_template(
"entry/new-order.html",
groups=groups,
locations=locations,
tax_groups=tax_groups
)
@bp.route("/select-snack-entry", methods=["GET", "POST"])
def edit_snack_data():
if "entry" not in session:
return redirect("/entry/edit-item-data")
snacks = db.run_query("get_snacks_by_barcode.sql", {
"snack_barcode": session["entry"]["item_barcode"]
}).fetchall()
# API routes for interactive JS stuff
return render_template("entry/select-snack-entry.html", **{
"entry": session["entry"],
"snacks": snacks
})
@bp.get("/api/search-items")
def api_search_items():
try:
search_term = request.args["search-term"]
except:
return {"error": "Missing query parameter `search-term`"}, 400
location = current_user.data.location
with db.run_query("search_items.sql", {
"location_id": location["location_id"] if location else None,
"search_term": search_term
}) as cursor:
items = cursor.fetchall()
return items

View File

@ -1,4 +1,5 @@
from flask import Blueprint, redirect, render_template, request, session
from flask import Blueprint, redirect, render_template, request
from flask_login import current_user
from . import db
@ -8,7 +9,7 @@ bp = Blueprint("inventory", __name__, url_prefix="/inventory")
@bp.get("/")
def index():
location = session.get("location", None)
location = current_user.data.location
items = db.run_query("get_inventory_overview.sql", {
"location_id": None if location is None else location["location_id"]
}).fetchall()
@ -20,7 +21,7 @@ def index():
@bp.get("/report")
def read_report():
location = session.get("location", None)
location = current_user.data.location
items = db.run_query("get_inventory_report.sql", {
"location_id": None if location is None else location["location_id"]
}).fetchall()

View File

@ -1,4 +1,5 @@
from flask import Blueprint, render_template, request, session
from flask import Blueprint, render_template, request
from flask_login import current_user
from . import db
@ -11,13 +12,12 @@ def index():
if request.method == "POST":
location_id = request.form.get("location_id", "")
if location_id == "":
session.pop("location", None)
current_user.data.location = None
else:
location = db.run_query("get_location_by_id.sql", {
"location_id": location_id}
).fetchone()
session["location"] = location
"location_id": location_id
}).fetchone()
current_user.data.location = location
locations = db.run_query("get_locations.sql").fetchall()

View File

@ -63,3 +63,14 @@ th {
.form-input > select {
display: block;
}
.form-input.--inline {
display: inline-block;
}
.form-input.--inline > input:not([type=radio]),
.form-input.--inline > select {
display: block;
width: 8em;
}
details {
font-size: 0.8em;
}

View File

@ -1,4 +1,4 @@
import datetime
import json
def format_currency(x):
@ -15,16 +15,5 @@ def format_bool(x):
return "" if x else ""
def now():
return datetime.datetime.now()
def get_garfield_price(net_unit_price, tax_group_id):
if tax_group_id == 1:
tax_factor = 1.19
elif tax_group_id == 2:
tax_factor = 1.07
else:
raise Error("Unknown tax group ID")
return net_unit_price * tax_factor + 0.01
def to_json(x):
return json.dumps(x)

View File

@ -15,10 +15,10 @@
<li{{ " class=current-page" if request.path.startswith("/entry") else "" }}><a href="/entry">Eintragen</a></li>
<li{{ " class=current-page" if request.path.startswith("/location") else "" }}>
<a href="/location">
{% if "location" not in session %}
{% if not current_user.data.location %}
Raum wählen
{% else %}
Raum: {{ session.location.location_name }}
Raum: {{ current_user.data.location.location_name }}
{% endif %}
</a>
</li>
@ -30,6 +30,16 @@
<details>
<summary><code>config</code></summary>
<pre>{% for key, value in config.items() %}{{ key }} = {{ value }}
{% endfor %}</pre>
</details>
<details>
<summary><code>session</code></summary>
<pre>{% for key, value in session.items() %}{{ key }} = {{ value }}
{% endfor %}</pre>
</details>
<details>
<summary><code>current_user.data</code></summary>
<pre>{% for key, value in current_user.data.__dict__.items() %}{{ key }} = {{ value }}
{% endfor %}</pre>
</details>
{% endif %}

View File

@ -1,56 +0,0 @@
{% extends "base.html" %}
{% block content %}
<pre>{{ entry }}</pre>
<fieldset>
<legend>Neuer Inventareintrag</legend>
<form method="POST" action="/entry/edit-item-data">
<div class="form-input">
<label for="item_bought">Kaufdatum</label>
<input name="item_bought" id="item_bought" type="date" value="{{ (entry.item_bought or now()).strftime('%Y-%m-%d') }}">
</div>
<div class="form-input">
<label for="item_barcode">Barcode</label>
<input name="item_barcode" id="item_barcode" type="text" value="{{ entry.item_barcode }}" placeholder="Barcode">
</div>
<div class="form-input">
<label for="item_name">Artikel</label>
<input name="item_name" id="item_name" type="text" value="{{ entry.item_name }}" placeholder="Artikel">
</div>
<div class="form-input">
<label for="item_group">Gruppe</label>
<select name="item_group" id="item_group">
{% for group in groups %}
<option value="{{ group.group_id }}"{% if entry.item_group_id == group.group_id %} selected{% endif %}>{{ group.group_name }} ({{ group.group_id }})</option>
{% endfor %}
</select>
</div>
<div class="form-input">
<label for="item_net_unit_price">Stückpreis (Netto) in €</label>
<input name="item_net_unit_price" id="item_net_unit_price" type="number" step="0.01" value="{{ entry.item_net_unit_price }}" placeholder="Stückpreis (Netto) in €">
</div>
<div class="form-input">
<input name="item_tax_group" id="item_tax_group_1" type="radio" value="1"{% if entry.item_tax_group_id == 1 %} selected{% endif %}>
<label for="item_tax_group_1">Volle Umsatzsteuer (19%)</label>
<input name="item_tax_group" id="item_tax_group_2" type="radio" value="2"{% if entry.item_tax_group_id == 2 %} selected{% endif %}>
<label for="item_tax_group_2">Ermäßigte Umsatzsteuer (7%)</label>
</div>
<div class="form-input">
<label for="item_amount">Anzahl</label>
<input name="item_amount" id="item_amount" type="number" value="{{ entry.item_amount }}" placeholder="Anzahl">
</div>
<div class="form-input">
<label for="item_group">Raum</label>
<select name="item_location" id="item_location">
{% for location in locations %}
<option value="{{ location.location_id }}"{% if entry.item_location_id == location.location_id or ("item_location" not in entry and (session.location.location_id == location.location_id)) %} selected{% endif %}>{{ location.location_name }} ({{ location.location_id }})</option>
{% endfor %}
</select>
</div>
<button>Weiter zu den Snackeinträgen</button>
</form>
</fieldset>
{% endblock %}

View File

@ -1,5 +1,42 @@
{% extends "base.html" %}
{% block content %}
<a href="/entry/edit-item-data">Neuer Eintrag</a>
<a href="/entry/new-order">Neuer Auftrag</a>
<h2>Aufträge</h2>
<table>
<tr>
<th>Barcode</th>
<th>Name</th>
<th>Eingekauft</th>
<th>Gruppe</th>
<th>Raum</th>
<th>Steuergruppe</th>
<th>EK-Preis (Netto)</th>
<th>VK-Preis (Brutto)</th>
<th>Aktionen</th>
</tr>
{% for cart_item in current_user.data.orders %}
<tr>
<td><code>{{ cart_item.barcode }}</code></td>
<td>{{ cart_item.name }}</td>
<td class="--align-right">{{ cart_item.sales_units }}</td>
<td class="--align-right">{{ cart_item.group_name }} ({{ cart_item.group_id }})</td>
<td class="--align-right">{{ cart_item.location_name }} ({{ cart_item.location_id }})</td>
<td class="--align-right">{{ cart_item.tax_group_description }} ({{ cart_item.tax_group_id }})</td>
<td class="--align-right">{{ format_currency(cart_item.net_unit_price) }}</td>
<td class="--align-right">{{ format_currency(cart_item.gross_unit_price) }}</td>
<td>
<form method="POST" action="/entry/delete-order">
<input type="hidden" name="order-index" value="{{ loop.index0 }}">
<button>Löschen</button>
</form>
</td>
</tr>
{% endfor %}
</table>
<form method="POST" action="/entry/add-new-items">
<input type="checkbox" name="i-know-what-im-doing" id="i-know-what-im-doing">
<label for="i-know-what-im-doing">I weiß, was ich tue</label>
<button>Neue Einträge anlegen</button>
</form>
{% endblock %}

View File

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block content %}
<div class="entry-app"></div>
<script src="{{ url_for('static', filename='entry.js') }}"></script>
<script>
Elm.Entry.init({
node: document.querySelector('.entry-app'),
flags: {
locations: {{ to_json(locations) | safe }},
groups: {{ to_json(groups) | safe }},
taxGroups: {{ to_json(tax_groups) | safe }}
}
});
</script>
{% endblock %}

View File

@ -1,83 +0,0 @@
{% extends "base.html" %}
{% block content %}
<pre>{{ entry }}</pre>
<fieldset>
<legend>Neuer Inventareintrag</legend>
<table>
<tr>
<th class="--align-left">ID</th>
<td>{{ entry.item_id }}</td>
</tr>
<tr>
<th class="--align-left">Barcode</th>
<td><code>{{ entry.item_barcode }}</code></td>
</tr>
<tr>
<th class="--align-left">Name</th>
<td>{{ entry.item_name }}</td>
</tr>
<tr>
<th class="--align-left">Einkaufspreis (Netto)</th>
<td>{{ format_currency(entry.item_net_unit_price) }}</td>
</tr>
<tr>
<th class="--align-left">Empfohlener Garfield-Verkaufspreis</th>
<td>{{ format_currency(get_garfield_price(entry.item_net_unit_price, entry.item_tax_group_id)) }}</td>
</tr>
<tr>
<th class="--align-left">Kaufdatum</th>
<td>{{ format_date(entry.item_bought) }}</td>
</tr>
<tr>
<th class="--align-left">Gruppe</th>
<td>{{ entry.item_group_name }} ({{ entry.item_group_id }})</td>
</tr>
<tr>
<th class="--align-left">Anzahl</th>
<td>{{ entry.item_amount }}</td>
</tr>
<tr>
<th class="--align-left">Raum</th>
<td>{{ entry.item_location_name }} ({{ entry.item_location_id }})</td>
</tr>
</table>
</fieldset>
<fieldset>
<legend>Snackeinträge mit Barcode <code>{{ entry.item_barcode }}</code></legend>
<table>
<tr>
<th>ID</th>
<th>Barcode</th>
<th>Name</th>
<th>Verkaufspreis (Brutto)</th>
<th>Eintragedatum</th>
<th>Steuersatz</th>
<th>Raum</th>
<th>Aktiv?</th>
<th>Aktionen</th>
</tr>
{% for snack in snacks %}
<tr>
<td>{{ snack.snack_id }}</td>
<td><code>{{ snack.snack_barcode }}</code></td>
<td>{{ snack.snack_name }}</td>
<td class="--align-right">{{ format_currency(snack.snack_price) }}</td>
<td>{{ format_date(snack.snack_timestamp) }}</td>
<td>{{ snack.description }} ({{ snack.tax_group_id }})</td>
<td>{{ snack.location_name }} ({{ snack.location_id }})</td>
<td>{{ format_bool(snack.snack_available) }}</td>
<td>
<form method="POST" action="/entry/select-snack-entry">
<input type="hidden" name="snack_id" value="{{ snack.snack_id }}">
<button>Snackeintrag übernehmen</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</fieldset>
{% endblock %}

View File

@ -3,9 +3,9 @@
{% block content %}
<form method="POST">
<select name="location_id">
<option value="" {{ "selected" if "location" not in session else ""}}>-</option>
<option value="" {{ "selected" if not current_user.data.location else ""}}>-</option>
{% for location in locations %}
<option value="{{ location.location_id }}" {{ "selected" if "location" in session and session.location.location_id == location.location_id else "" }}>{{ location.location_name }}</option>
<option value="{{ location.location_id }}" {{ "selected" if current_user.data.location.location_id == location.location_id else "" }}>{{ location.location_name }}</option>
{% endfor %}
</select>

View File

@ -1,6 +1,7 @@
blinker==1.6.2
click==8.1.3
Flask==2.3.2
Flask-Login==0.6.2
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.2