20 changed files with 365 additions and 118 deletions
+25
View File
@@ -0,0 +1,25 @@
FROM debian:latest as builder
RUN apt-get update
RUN apt-get upgrade -y
RUN apt-get install -y elm-compiler make ca-certificates
COPY . /app
WORKDIR /app
RUN rm -rf .venv venv
RUN make frontend
FROM python:3.11-alpine as runner
COPY --from=builder /app /app
WORKDIR /app
RUN pip install -r requirements.txt
RUN pip install gunicorn
EXPOSE 5000
ENV JON_DB_CONNECTION_STRING="host=fsmi-db.fsmi.org dbname=garfield"
ENV JON_SECRET_KEY="changeme"
CMD ["sh", "-c", "gunicorn -b '0.0.0.0:5000' --chdir /app 'jon:create_app()'"]
+28
View File
@@ -4,6 +4,11 @@
## Setup ## Setup
`jon` is a Python WSGI application written using Flask.
This means you'll have to install a bunch of Python packages to get up and running.
### Dependencies
``` ```
pip install -r requirements.txt pip install -r requirements.txt
``` ```
@@ -11,6 +16,14 @@ pip install -r requirements.txt
You should probably use a virtualenv for that. You should probably use a virtualenv for that.
I develop `jon` using Python 3.10 but it should work with older versions as well. I develop `jon` using Python 3.10 but it should work with older versions as well.
#### Arch Linux
If you're on Arch, these packages are required for running the server:
```
python python-flask python-flask-login python-psycopg2
```
### Building Frontend JS ### Building Frontend JS
Most of jon works without JS but there are some features that require it. Most of jon works without JS but there are some features that require it.
@@ -32,6 +45,20 @@ flask --app jon run --debug
`--debug` restarts the server when a source file changes. `--debug` restarts the server when a source file changes.
## Running with docker
When you prefer running the application using docker you can just use
```
docker compose up
```
In case your local username does not line up with your FSMI-username, you need to specify your FSMI-username
using `USER=<username>`, e.g.:
```
USER=shirkanesi docker compose up
```
This can also be persisted by following the instructions in the docker-compose.yml
## fsmi-db forward ## fsmi-db forward
``` ```
@@ -55,3 +82,4 @@ ssh -nNTvL 5432:fsmi-db.fsmi.org:5432 fsmi-login.fsmi.uni-karlsruhe.de
- [x] Figure out/Add documentation about building `entry.js` - [x] Figure out/Add documentation about building `entry.js`
- [ ] Clean up the code a little and add some comments - [ ] Clean up the code a little and add some comments
- [ ] Needs good documentation for maintainability - [ ] Needs good documentation for maintainability
- [ ] Use cool new function for deactivating items
+19
View File
@@ -0,0 +1,19 @@
---
version: '3.7'
services:
jon:
container_name: jon
build: .
ports:
- "5000:5000"
volumes:
- ~/.pgpass:/root/.pgpass:ro
dns:
- 1.1.1.1
- 8.8.8.8
network_mode: bridge
environment:
# If your local user is different from your fsmi user, change $USER here!
- JON_DB_CONNECTION_STRING="host=fsmi-db.fsmi.org dbname=garfield user=$USER"
- JON_SECRET_KEY="changemetosomethingsuperrandomandsecure"
+32 -4
View File
@@ -133,8 +133,11 @@ type alias Context =
type alias Globals = type alias Globals =
{ locations : List Location { locations : List Location
, defaultLocation : Location
, groups : List Group , groups : List Group
, defaultGroup : Group
, taxGroups : List TaxGroup , taxGroups : List TaxGroup
, defaultTaxGroup : TaxGroup
} }
type State type State
@@ -158,7 +161,7 @@ type Msg
= SetSearchTerm String = SetSearchTerm String
| SubmitSearch | SubmitSearch
| ReceiveSearchResults (Result Http.Error (List SearchResult)) | ReceiveSearchResults (Result Http.Error (List SearchResult))
| GotoItemEditor SearchResult | GotoItemEditor IEInit
| SetBarcode String | SetBarcode String
| SetName String | SetName String
| SetSalesUnits String | SetSalesUnits String
@@ -169,6 +172,10 @@ type Msg
| SetLocation String | SetLocation String
| SetTaxGroup String | SetTaxGroup String
type IEInit
= IEInitBarcode String
| IEInitSearchResult SearchResult
-- Update logic: State machine etc. -- Update logic: State machine etc.
update msg { globals, state } = update msg { globals, state } =
@@ -190,7 +197,21 @@ updateState msg globals state = case state of
) )
ReceiveSearchResults (Ok searchResults) -> ReceiveSearchResults (Ok searchResults) ->
(ItemSearch { model | searchResults = searchResults }, Cmd.none) (ItemSearch { model | searchResults = searchResults }, Cmd.none)
GotoItemEditor searchResult -> GotoItemEditor (IEInitBarcode barcode) ->
( ItemEditor
{ barcode = barcode
, name = ""
, calculator = Calculator.init 0
, netUnitPrice = NumberInput.fromFloat 0
, grossUnitPrice = NumberInput.fromFloat 0
, salesUnits = NumberInput.fromInt 0
, group = Select.init (.id >> String.fromInt) (.name) globals.defaultGroup globals.groups
, location = Select.init (.id >> String.fromInt) (.name) globals.defaultLocation globals.locations
, taxGroup = Select.init (.id >> String.fromInt) (.description) globals.defaultTaxGroup globals.taxGroups
}
, Cmd.none
)
GotoItemEditor (IEInitSearchResult searchResult) ->
case find (\tg -> tg.id == searchResult.taxGroupId) globals.taxGroups of case find (\tg -> tg.id == searchResult.taxGroupId) globals.taxGroups of
Nothing -> (state, Cmd.none) Nothing -> (state, Cmd.none)
Just taxGroup -> Just taxGroup ->
@@ -245,7 +266,13 @@ suggestedGrossPrice netPrice percentage =
view { globals, state } = case state of view { globals, state } = case state of
ItemSearch model -> ItemSearch model ->
fieldset [] div []
[ div []
[ if model.searchTerm == ""
then button [ disabled True ] [ text "Neuer Artikel" ]
else button [ onClick <| GotoItemEditor <| IEInitBarcode model.searchTerm ] [ text <| "Neuer Artikel mit Barcode " ++ model.searchTerm ]
]
, fieldset []
[ legend [] [ text "Vorlage für Auftrag wählen" ] [ legend [] [ text "Vorlage für Auftrag wählen" ]
, Html.form [ onSubmit SubmitSearch ] , Html.form [ onSubmit SubmitSearch ]
[ div [ class "form-input" ] [ div [ class "form-input" ]
@@ -255,6 +282,7 @@ view { globals, state } = case state of
, table [] <| searchResultHeaders :: List.map viewSearchResult model.searchResults , table [] <| searchResultHeaders :: List.map viewSearchResult model.searchResults
] ]
] ]
]
ItemEditor model -> ItemEditor model ->
Html.form [ method "POST" ] Html.form [ method "POST" ]
[ fieldset [] [ fieldset []
@@ -385,7 +413,7 @@ viewSearchResult model =
, td [] [ text model.locationName ] , td [] [ text model.locationName ]
, td [] [ text <| showBool model.available ] , td [] [ text <| showBool model.available ]
, td [] , td []
[ Html.form [ onSubmit <| GotoItemEditor model ] [ Html.form [ onSubmit <| GotoItemEditor <| IEInitSearchResult model ]
[ button [] [ text "Als Vorlage verwenden" ] [ button [] [ text "Als Vorlage verwenden" ]
] ]
] ]
+2 -6
View File
@@ -20,14 +20,10 @@ def create_app():
# You don't need a config.json. If you don't provide one, default-config.json # You don't need a config.json. If you don't provide one, default-config.json
# is used. # is used.
app.config.from_file("config.json", load=json.load, silent=True) app.config.from_file("config.json", load=json.load, silent=True)
app.config.from_prefixed_env(prefix="JON")
db.init_app(app) db.init_app(app)
auth.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()
@app.context_processor @app.context_processor
def utility_processor(): def utility_processor():
+53 -10
View File
@@ -1,7 +1,10 @@
import secrets import secrets
import string 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") bp = Blueprint("auth", __name__, url_prefix="/auth")
@@ -15,27 +18,67 @@ ALLOWED_PATHS = [
] ]
# 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]]
def __init__(self):
self.location = None
self.orders = []
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(): 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 "token" in request.args:
if request.args["token"] == ACCESS_TOKEN: if request.args["token"] == ACCESS_TOKEN:
session["authenticated"] = () login_user(the_one_and_only_user)
# Reload the page without query parameters # Reload the page without query parameters
return redirect(request.path) return redirect(request.path)
# Don't deny any paths in `ALLOWED_PATHS` # Never deny any paths in `ALLOWED_PATHS`
if request.path in ALLOWED_PATHS: if request.path in ALLOWED_PATHS:
return return
if not "authenticated" in session: if not current_user.is_authenticated:
return render_template("auth/denied.html"), 403 return render_template("auth/denied.html"), 403
@bp.get("/logout") @bp.get("/logout")
def logout(): def logout():
session.pop("authenticated", None) logout_user()
return redirect("/") return redirect("/")
+53
View File
@@ -58,3 +58,56 @@ FROM garfield.inventory_items
) m USING (item_id) ) m USING (item_id)
ORDER BY inventory_items.name; ORDER BY inventory_items.name;
-- How many *other* active inventory lines exist with the same barcode in the same location that are newer?
CREATE TEMPORARY VIEW more_recent_inventory_lines_with_same_barcode AS
SELECT
a.item_id,
-- It's important not to count(*) here because item_id is NULL
-- when no other inventory lines exist.
count(b.item_id) AS other_lines_count
FROM garfield.inventory_items AS a
LEFT JOIN garfield.inventory_items AS b
ON a.item_barcode = b.item_barcode
AND a.item_id != b.item_id
AND a.location = b.location
AND b.available
AND b.bought > a.bought
GROUP BY a.item_id;
-- We need to create this table so that we can put an index on it
-- Otherwise the join in the consumption graph query becomes much slower
-- Perhaps it would be nicer to use a materialized view instead
DROP TABLE IF EXISTS last_n_days;
CREATE TEMPORARY TABLE last_n_days AS (
SELECT
generate_series(now() - interval '14 days', now(), interval '1 day')::date AS sale_date
);
CREATE UNIQUE INDEX last_n_days_sale_date ON last_n_days (sale_date);
-- Get an array of how often items were sold over the last 14 days
CREATE TEMPORARY VIEW inventory_last_n_days_sales AS
WITH
sales_by_date AS (
SELECT
inventory_line AS item_id,
date_trunc('day', snack_sales_log_timestamp AT TIME ZONE 'UTC+1') AS sale_date,
count(*)::int AS sales
FROM garfield.snack_sales_log
GROUP BY item_id, sale_date
),
beeg AS (
SELECT item_id, sale_date, count(snack_sales_log_timestamp)::int AS sales
FROM garfield.inventory_items
CROSS JOIN last_n_days
LEFT JOIN garfield.snack_sales_log
ON inventory_line = item_id
-- snack_sales_log has an index on snack_sales_log_timestamp to speed up this query
-- If that index doesn't exist the query takes much longer.
AND sale_date = date_trunc('day', snack_sales_log_timestamp AT TIME ZONE 'UTC+1')
WHERE available
GROUP BY item_id, sale_date
ORDER BY item_id, sale_date
)
SELECT item_id, array_agg(sales) AS last_n_days_sales
FROM beeg
GROUP BY item_id
+2
View File
@@ -1,6 +1,8 @@
SELECT SELECT
* *
FROM all_inventory_item_overview FROM all_inventory_item_overview
LEFT JOIN more_recent_inventory_lines_with_same_barcode USING (item_id)
LEFT JOIN inventory_last_n_days_sales USING (item_id)
WHERE (%(location_id)s IS NULL OR location = %(location_id)s) WHERE (%(location_id)s IS NULL OR location = %(location_id)s)
AND available AND available
ORDER BY ORDER BY
+33 -39
View File
@@ -1,49 +1,43 @@
-- parameters:
--
-- location_id: Location to generate the report for
WITH WITH
most_recent_sales AS ( sales_by_item_id AS (
SELECT DISTINCT ON (inventory_line) SELECT
inventory_line, snack_sales_log_id, snack_sales_log_timestamp AS most_recent_sale inventory_line AS item_id,
max(snack_sales_log_timestamp) AS last_sold,
count(*)::int AS units_sold
FROM garfield.snack_sales_log FROM garfield.snack_sales_log
ORDER BY inventory_line ASC, snack_sales_log_timestamp DESC WHERE type_id = 'SNACK_BUY'
AND inventory_line IS NOT NULL
AND snack_sales_log_timestamp > NOW() - INTERVAL '120 days'
AND location_id = %(location_id)s
GROUP BY item_id
), ),
enhanced_overview1 AS ( sales_by_barcode AS (
SELECT SELECT
inventory_items.item_id, item_barcode,
inventory_items.item_barcode, max(name) AS name,
inventory_items.name, sum(units_sold) AS units_sold,
units_left, round(avg((units_sold / extract(epoch FROM (CASE WHEN available THEN now() ELSE last_sold END) - bought)) * 86400)::numeric, 2) AS sold_per_day
inventory_items.sales_units, FROM sales_by_item_id
correction_delta,
location_name,
location,
CASE
WHEN snack_sales_log_id IS NULL THEN 0
ELSE sales / (EXTRACT(EPOCH FROM (
CASE
WHEN units_left <= 0 THEN most_recent_sale
ELSE NOW()
END
)) - EXTRACT(EPOCH FROM bought)) * 24 * 3600
END AS per_day
FROM garfield.inventory_item_overview
LEFT JOIN garfield.inventory_items USING (item_id) LEFT JOIN garfield.inventory_items USING (item_id)
LEFT JOIN most_recent_sales ON item_id = inventory_line GROUP BY item_barcode
), ),
enhanced_overview2 AS ( current_inventory AS (
SELECT SELECT
*, item_barcode,
CASE sum(units_left)::int AS units_left
WHEN per_day = 0 THEN NULL FROM all_inventory_item_overview
ELSE GREATEST(0, units_left / per_day) WHERE available
END AS days_left AND location = %(location_id)s
FROM enhanced_overview1 GROUP BY item_barcode
) )
SELECT SELECT
*, sales_by_barcode.*,
CASE COALESCE(current_inventory.units_left, 0)::int AS units_left
WHEN days_left IS NULL THEN NULL FROM sales_by_barcode
ELSE GREATEST(0, (60 - days_left) * per_day) LEFT JOIN current_inventory USING (item_barcode)
END AS for_two_months ORDER BY units_sold DESC
FROM enhanced_overview2
WHERE (%(location_id)s IS NULL OR location = %(location_id)s)
ORDER BY days_left ASC, per_day DESC
+12 -19
View File
@@ -1,5 +1,5 @@
from flask import Blueprint, flash, redirect, render_template, request, session from flask import Blueprint, flash, redirect, render_template, request
from flask_login import current_user
from . import db from . import db
@@ -9,22 +9,18 @@ bp = Blueprint("entry", __name__, url_prefix="/entry")
@bp.route("/", methods=["GET", "POST"]) @bp.route("/", methods=["GET", "POST"])
def index(): def index():
cart = session.get("cart", default=[])
return render_template( return render_template(
"entry/index.html", "entry/index.html"
cart=cart
) )
@bp.post("/add-new-items") @bp.post("/add-new-items")
def add_new_entries(): def add_new_entries():
print(session)
i_know_what_im_doing = "i-know-what-im-doing" in request.form i_know_what_im_doing = "i-know-what-im-doing" in request.form
if not i_know_what_im_doing: if not i_know_what_im_doing:
return "Du weißt nicht was du tust", 400 return "Du weißt nicht was du tust", 400
orders = session.get("cart", default=[]) orders = current_user.data.orders
if not orders: if not orders:
return "Keine Aufträge", 404 return "Keine Aufträge", 404
@@ -36,7 +32,7 @@ def add_new_entries():
db.get_db().commit() db.get_db().commit()
# Reset the cart # Reset the cart
session["cart"] = [] current_user.data.orders = []
return redirect(request.referrer) return redirect(request.referrer)
@@ -48,9 +44,7 @@ def delete_order():
except: except:
return "Incomplete or mistyped form", 400 return "Incomplete or mistyped form", 400
cart = session.get("cart", default=[]) del current_user.data.orders[order_index]
del cart[order_index]
session["cart"] = cart
return redirect(request.referrer) return redirect(request.referrer)
@@ -76,9 +70,7 @@ def new_order():
except: except:
return f"Incomplete or mistyped form", 400 return f"Incomplete or mistyped form", 400
cart = session.get("cart", default=[]) current_user.data.orders.append({
print(cart)
cart.append({
"barcode": barcode, "barcode": barcode,
"name": name, "name": name,
"sales_units": sales_units, "sales_units": sales_units,
@@ -91,7 +83,6 @@ def new_order():
"net_unit_price": net_unit_price, "net_unit_price": net_unit_price,
"gross_unit_price": gross_unit_price "gross_unit_price": gross_unit_price
}) })
session["cart"] = cart
return redirect("/entry") return redirect("/entry")
with db.run_query("entry/get_groups.sql") as cursor: with db.run_query("entry/get_groups.sql") as cursor:
@@ -103,10 +94,13 @@ def new_order():
with db.run_query("entry/get_tax_groups.sql") as cursor: with db.run_query("entry/get_tax_groups.sql") as cursor:
tax_groups = cursor.fetchall() tax_groups = cursor.fetchall()
selected_location = current_user.data.location
return render_template( return render_template(
"entry/new-order.html", "entry/new-order.html",
groups=groups, groups=groups,
locations=locations, locations=locations,
default_location=next(location for location in locations if location["id"] == selected_location["location_id"]) if selected_location is not None else locations[0],
tax_groups=tax_groups tax_groups=tax_groups
) )
@@ -122,10 +116,9 @@ def api_search_items():
except: except:
return {"error": "Missing query parameter `search-term`"}, 400 return {"error": "Missing query parameter `search-term`"}, 400
location = session.get("location", None) location = current_user.data.location
with db.run_query("search_items.sql", { with db.run_query("search_items.sql", {
"location_id": None if location is None else location["location_id"], "location_id": location["location_id"] if location else None,
"search_term": search_term "search_term": search_term
}) as cursor: }) as cursor:
items = cursor.fetchall() items = cursor.fetchall()
+11 -4
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 from . import db
@@ -8,7 +9,7 @@ bp = Blueprint("inventory", __name__, url_prefix="/inventory")
@bp.get("/") @bp.get("/")
def index(): def index():
location = session.get("location", None) location = current_user.data.location
items = db.run_query("get_inventory_overview.sql", { items = db.run_query("get_inventory_overview.sql", {
"location_id": None if location is None else location["location_id"] "location_id": None if location is None else location["location_id"]
}).fetchall() }).fetchall()
@@ -20,12 +21,18 @@ def index():
@bp.get("/report") @bp.get("/report")
def read_report(): def read_report():
location = session.get("location", None) location = current_user.data.location
if location is None:
# TODO: Error handling
return "please select a location in order to generate a report", 400
items = db.run_query("get_inventory_report.sql", { items = db.run_query("get_inventory_report.sql", {
"location_id": None if location is None else location["location_id"] "location_id": location["location_id"]
}).fetchall() }).fetchall()
return render_template("inventory/read_report.html", **{ return render_template("inventory/read_report.html", **{
"location": location,
"items": items "items": items
}) })
+6 -6
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 from . import db
@@ -11,13 +12,12 @@ def index():
if request.method == "POST": if request.method == "POST":
location_id = request.form.get("location_id", "") location_id = request.form.get("location_id", "")
if location_id == "": if location_id == "":
session.pop("location", None) current_user.data.location = None
else: else:
location = db.run_query("get_location_by_id.sql", { location = db.run_query("get_location_by_id.sql", {
"location_id": location_id} "location_id": location_id
).fetchone() }).fetchone()
session["location"] = location current_user.data.location = location
locations = db.run_query("get_locations.sql").fetchall() locations = db.run_query("get_locations.sql").fetchall()
+14
View File
@@ -40,6 +40,9 @@ nav > ul > li + li:before {
.--centered { .--centered {
text-align: center; text-align: center;
} }
.--not-important {
color: #aaa;
}
@keyframes wiggle { @keyframes wiggle {
0%, 100% { margin-top: 0; } 0%, 100% { margin-top: 0; }
50% { margin-top: -0.5em; } 50% { margin-top: -0.5em; }
@@ -55,6 +58,10 @@ th {
body { body {
font-size: 8px; font-size: 8px;
} }
/* hide the menu when printing */
header {
display: none;
}
} }
.form-input > label { .form-input > label {
font-size: .8em; font-size: .8em;
@@ -71,3 +78,10 @@ th {
display: block; display: block;
width: 8em; width: 8em;
} }
details {
font-size: 0.8em;
}
.consumption-graph {
display: block;
height: 1em;
}
+13 -2
View File
@@ -12,13 +12,14 @@
<ul> <ul>
<li{{ " class=current-page" if request.path == "/" else "" }}><a href="/">Home</a></li> <li{{ " class=current-page" if request.path == "/" else "" }}><a href="/">Home</a></li>
<li{{ " class=current-page" if request.path.startswith("/inventory") else "" }}><a href="/inventory">Inventar</a></li> <li{{ " class=current-page" if request.path.startswith("/inventory") else "" }}><a href="/inventory">Inventar</a></li>
<li><a href="/inventory/report">Einkäuferbericht</a></li>
<li{{ " class=current-page" if request.path.startswith("/entry") else "" }}><a href="/entry">Eintragen</a></li> <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 "" }}> <li{{ " class=current-page" if request.path.startswith("/location") else "" }}>
<a href="/location"> <a href="/location">
{% if "location" not in session %} {% if not current_user.data.location %}
Raum wählen Raum wählen
{% else %} {% else %}
Raum: {{ session.location.location_name }} Raum: {{ current_user.data.location.location_name }}
{% endif %} {% endif %}
</a> </a>
</li> </li>
@@ -30,6 +31,16 @@
<details> <details>
<summary><code>config</code></summary> <summary><code>config</code></summary>
<pre>{% for key, value in config.items() %}{{ key }} = {{ value }} <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> {% endfor %}</pre>
</details> </details>
{% endif %} {% endif %}
+1 -1
View File
@@ -15,7 +15,7 @@
<th>VK-Preis (Brutto)</th> <th>VK-Preis (Brutto)</th>
<th>Aktionen</th> <th>Aktionen</th>
</tr> </tr>
{% for cart_item in cart %} {% for cart_item in current_user.data.orders %}
<tr> <tr>
<td><code>{{ cart_item.barcode }}</code></td> <td><code>{{ cart_item.barcode }}</code></td>
<td>{{ cart_item.name }}</td> <td>{{ cart_item.name }}</td>
+4 -1
View File
@@ -8,8 +8,11 @@ Elm.Entry.init({
node: document.querySelector('.entry-app'), node: document.querySelector('.entry-app'),
flags: { flags: {
locations: {{ to_json(locations) | safe }}, locations: {{ to_json(locations) | safe }},
defaultLocation: {{ to_json(default_location) | safe }},
groups: {{ to_json(groups) | safe }}, groups: {{ to_json(groups) | safe }},
taxGroups: {{ to_json(tax_groups) | safe }} defaultGroup: {{ to_json(groups[0]) | safe }},
taxGroups: {{ to_json(tax_groups) | safe }},
defaultTaxGroup: {{ to_json(tax_groups[0]) | safe }},
} }
}); });
</script> </script>
+30
View File
@@ -1,9 +1,34 @@
{% macro consumption_graph_svg(values) -%}
{% set stroke_width = 8 %}
{% set width = 300 %}
{% set height = 100 %}
{% set padding = 4 %}
{% set dx = (width - 2 * padding) / ((values | length) + 1) %}
{% set dy = (height - 2 * padding) / ((values + [1]) | max) %}
<svg viewBox="0 0 {{ width }} {{ height }}" role="img" class="consumption-graph">
<polyline
points="
{% for value in values %}
{{ padding + loop.index * dx }}, {{ height - padding - value * dy }}
{% endfor %}
"
stroke="green"
stroke-width="{{ stroke_width }}"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
>
</svg>
{% endmacro -%}
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<table> <table>
<tr> <tr>
<th>Stat</th>
<th>ID</th> <th>ID</th>
<th>Graph</th>
<th>Barcode</th> <th>Barcode</th>
<th>Name</th> <th>Name</th>
<th>Preis (Netto)</th> <th>Preis (Netto)</th>
@@ -17,7 +42,12 @@
</tr> </tr>
{% for item in items %} {% for item in items %}
<tr> <tr>
<td>
{% if item.units_left == 0 %}<span title="Leerer aktiver Inventareintrag">🅾️</span>{% endif %}
{% if item.other_lines_count != 0 %}<span title="{% if item.other_lines_count == 1 %}Neuerer Eintrag mit demselben Barcode ist aktiv{% else %}{{ item.other_lines_count }} Einträge mit demselben Barcode sind aktiv{% endif %}">🔄</span>{% endif %}
</td>
<td><a href="/inventory/item/{{ item.item_id }}">{{ item.item_id }}</a></td> <td><a href="/inventory/item/{{ item.item_id }}">{{ item.item_id }}</a></td>
<td>{{ consumption_graph_svg(item.last_n_days_sales) }}</td>
<td><code>{{ item.item_barcode }}</code></td> <td><code>{{ item.item_barcode }}</code></td>
<td>{{ item.name }}</td> <td>{{ item.name }}</td>
<td class="--align-right">{{ format_currency(item.unit_price) }}</td> <td class="--align-right">{{ format_currency(item.unit_price) }}</td>
+10 -10
View File
@@ -1,29 +1,29 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<h2>Einkäuferbericht für {{ location.location_name }}</h2>
<table> <table>
<tr> <tr>
<th>ID</th>
<th>Barcode</th> <th>Barcode</th>
<th>Name</th> <th>Name</th>
<th>Inventar</th> <th>Inventar</th>
<th>Gesamt</th> <th title="In den letzten 4 Monaten verkauft">Verkauft</th>
<th>Raum</th>
<th>Verbrauch [1/d]</th> <th>Verbrauch [1/d]</th>
<th>Verbrauch [1/60d]</th>
<!--
<th title="Estimated Time Until Empty">ETUE [d]</th> <th title="Estimated Time Until Empty">ETUE [d]</th>
<th>Für 2m</th> <th>Für 2m</th>
-->
</tr> </tr>
{% for item in items %} {% for item in items %}
<tr> <tr{% if item.units_left >= item.units_sold %} class="--not-important"{% endif %}>
<td><a href="/inventory/item/{{ item.item_id }}">{{ item.item_id }}</a></td>
<td><code>{{ item.item_barcode }}</code></td> <td><code>{{ item.item_barcode }}</code></td>
<td>{{ item.name }}</td> <td>{{ item.name }}</td>
<td class="--align-right">{{ item.units_left }}</td> <td class="--align-right">{{ item.units_left }}</td>
<td class="--align-right">{{ item.sales_units + item.correction_delta }}</td> <td class="--align-right">{{ item.units_sold }}</td>
<td>{{ item.location_name }}</td> <td class="--align-right">{{ item.sold_per_day }}</td>
<td class="--align-right">{{ item.per_day|round(2) }}</td> <td class="--align-right">{{ item.sold_per_day * 60 }}</td>
<td class="--align-right">{% if item.days_left != None %}{{ item.days_left|round(1) }}{% endif %}</td>
<td class="--align-right">{% if item.for_two_months %}{{ item.for_two_months|round(1) }}{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
+2 -2
View File
@@ -3,9 +3,9 @@
{% block content %} {% block content %}
<form method="POST"> <form method="POST">
<select name="location_id"> <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 %} {% 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 %} {% endfor %}
</select> </select>
+1
View File
@@ -1,6 +1,7 @@
blinker==1.6.2 blinker==1.6.2
click==8.1.3 click==8.1.3
Flask==2.3.2 Flask==2.3.2
Flask-Login==0.6.2
itsdangerous==2.1.2 itsdangerous==2.1.2
Jinja2==3.1.2 Jinja2==3.1.2
MarkupSafe==2.1.2 MarkupSafe==2.1.2