Author SHA1 Message Date
paul ab53b31ec0 Add todo 2024-05-13 19:31:50 +02:00
paul a201ce00b6 Add temporary table with index for speeding up consumption graph query 2024-05-13 19:30:12 +02:00
paul 630a0435a2 Add inline SVG consumption graphs 2023-12-13 13:44:28 +01:00
paul 61cf7696a5 Merge branch 'feature/audit-icons' 2023-12-13 13:38:56 +01:00
paul 3d9ad49020 Add icons to inventory page that indicate common issues 2023-12-05 17:18:39 +01:00
paul 1b6d0c40f3 Merge branch 'feature/auth-token-flask-login' 2023-12-05 15:52:01 +01:00
paul 235ef32efd Add Arch Linux dependencies to readme 2023-12-05 15:50:27 +01:00
paul 85598adaab Use flask-login instead of client-side sessions for storing selected location and orders 2023-08-22 18:31:42 +02:00
paul 70934a2f85 Add flask-login 2023-08-22 18:31:42 +02:00
Lukas Brocke 5cec28cad1 Merge pull request 'Item and Snack Entry' (#4) from feature/entry into main
Reviewed-on: https://git.fsmi.org/paul/jon/pulls/4
2023-08-22 18:24:16 +02:00
paul d57e66c033 Add some types and doc to frontend code 2023-08-22 16:56:04 +02:00
paul d9114d21c7 Some doc stuff 2023-08-22 13:14:56 +02:00
paul 2108ef2418 Add unitsLeft field to SearchResult type 2023-08-22 13:14:56 +02:00
paul a2d1d42362 Implement adding new inventory items 2023-08-22 13:14:56 +02:00
paul 32bcb1b18a fixup! Store location name, group name and tax group description in order 2023-08-22 13:14:56 +02:00
paul 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
paul a82e75999b Restyle calculator 2023-08-22 13:14:56 +02:00
paul 7b95b69c7b Make it possible to delete orders from the cart 2023-08-22 13:14:56 +02:00
paul e0aef726bd Implement adding orders to the cart and viewing it 2023-08-22 13:14:56 +02:00
paul 7707a9da6d Make entry for submittable 2023-08-22 13:14:56 +02:00
paul ca2e2e2e4a Refactor calculator into its own module 2023-08-22 13:14:56 +02:00
paul 208cc54e53 Implement price calculator 2023-08-22 13:14:56 +02:00
paul f7dd572fe1 Add note about entry.js to readme 2023-08-22 13:14:56 +02:00
paul 69dba202d0 Move around gross unit price input 2023-08-22 13:14:56 +02:00
paul 3d6bd7bac5 Rewrite some of the grossUnitPrice stuff 2023-08-22 13:14:56 +02:00
paul 0f9be13e31 Refactor fronted somewhat 2023-08-22 13:14:56 +02:00
paul be86248cce First draft of elm frontend
This will probably be scrapped or rewritten
2023-08-22 13:14:54 +02:00
paul 01d060cf5f Remove old entry stuff 2023-08-22 13:14:15 +02:00
paul 8c5b34073d Add note about unsafe client sessions 2023-08-21 16:05:28 +02:00
16 changed files with 266 additions and 60 deletions
+17
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.
@@ -46,9 +59,13 @@ ssh -nNTvL 5432:fsmi-db.fsmi.org:5432 fsmi-login.fsmi.uni-karlsruhe.de
- [ ] etc. - [ ] etc.
- [ ] Make it print nicely - [ ] Make it print nicely
- [ ] Make it possible to edit entries - [ ] 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 - [ ] Improve project structure
- [ ] Use `flask.flash` for error messages - [ ] Use `flask.flash` for error messages
- [x] Implement item and snack entry as Elm application - [x] Implement item and snack entry as Elm application
- [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
+16 -3
View File
@@ -9,7 +9,15 @@ import Select
type Tax = Net | Gross type Tax = Net | Gross
ctShow ct = case ct of -- Duplicated from Entry.elm but too lazy to sandwich this out
type alias TaxGroup =
{ id : Int
, description : String
, percentage : Float
}
showTax : Tax -> String
showTax tax = case tax of
Gross -> "Brutto" Gross -> "Brutto"
Net -> "Netto" Net -> "Netto"
@@ -19,11 +27,13 @@ type alias Model =
, bundleSize : NumberInput.Model Int , bundleSize : NumberInput.Model Int
} }
init : Float -> Model
init bundlePrice = Model init bundlePrice = Model
(Select.init ctShow ctShow Net [Net, Gross]) (Select.init showTax showTax Net [Net, Gross])
(NumberInput.fromFloat bundlePrice) (NumberInput.fromFloat bundlePrice)
(NumberInput.fromInt 1) (NumberInput.fromInt 1)
getResult : Model -> TaxGroup -> Maybe Float
getResult model taxGroup = getResult model taxGroup =
case (NumberInput.get model.bundlePrice, NumberInput.get model.bundleSize) of case (NumberInput.get model.bundlePrice, NumberInput.get model.bundleSize) of
(Just bundlePrice, Just bundleSize) -> (Just bundlePrice, Just bundleSize) ->
@@ -41,6 +51,7 @@ type Msg
| SetBundlePrice String | SetBundlePrice String
| SetBundleSize String | SetBundleSize String
update : Msg -> Model -> Model
update msg model = case msg of update msg model = case msg of
SetTax key -> SetTax key ->
{ model | tax = Select.update key model.tax } { model | tax = Select.update key model.tax }
@@ -49,6 +60,7 @@ update msg model = case msg of
SetBundleSize str -> SetBundleSize str ->
{ model | bundleSize = NumberInput.update str model.bundleSize } { model | bundleSize = NumberInput.update str model.bundleSize }
view : Model -> TaxGroup -> Html Msg
view model taxGroup = view model taxGroup =
let let
mainPart = mainPart =
@@ -112,4 +124,5 @@ view model taxGroup =
] ]
] ]
roundTo places x = toFloat (round <| x * 10 ^ places) / 10 ^ places roundTo : Int -> Float -> Float
roundTo places x = toFloat (round <| x * 10 ^ toFloat places) / 10 ^ toFloat places
+44
View File
@@ -14,6 +14,50 @@ import Calculator
import NumberInput import NumberInput
import Select import Select
{-
Elm forces us to use the Elm architecture:
┌──────┐
┌─────Model──► view ├──Html───────┐
│ └──────┘ │
│ │
┌┴─────────────────────────────────▼┐
│ Elm runtime │
└▲─────────────────────────────────┬┘
│ │
│ ┌──────┐ │
└─Model+Cmd──┤update◄──Msg+Model──┘
└──────┘
This architecture is similar to what React does but its implementation
in Elm is a bit special since it's purely functional and side effects
are isolated into the runtime system.
An Elm component is usually centered around two types, Model and Msg.
Model contains all data the application is concerned with, including the state
of UI elements. Msg encodes all updates to Model that the application supports.
In addition to Msg and Model, we have to provide two functions, view and update.
view : Model -> Html Msg
update : Msg -> Model -> (Model, Cmd Msg)
view maps a Model to a DOM tree. Events in this DOM tree create Msg values.
update maps a Msg and a Model to a new Model. In addition, update can create
a command. Commands are used to make the runtime do side effects, which in
turn create new Msg values.
For example, we have a SetSearchTerm message which simply updates the searchTerm
property in the model. This message is triggered every time the search box input
is changed. Submitting the search box form triggers a SubmitSearch event.
This event leaves the model unchanged but issues a command that sends the search
term to a JSON endpoint. When the request successfully resolves, the runtime
triggers a ReceiveSearchResults messages which updates the list of search results
in the model.
See Calculator.elm for a simpler example of this architecture.
-}
main = Browser.element main = Browser.element
{ init = \globals -> { init = \globals ->
( Context globals <| ItemSearch { searchTerm = "", searchResults = [] } ( Context globals <| ItemSearch { searchTerm = "", searchResults = [] }
+1 -6
View File
@@ -22,12 +22,7 @@ def create_app():
app.config.from_file("config.json", load=json.load, silent=True) app.config.from_file("config.json", load=json.load, silent=True)
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():
+54 -11
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 = [
] ]
def before_request(): # A poor man's replacement for memory-backed session solution.
""" # We keep exactly one User (and the corresponding UserData) in
If the correct token query parameter is passed along with any request, # memory and use that to store session data.
we mark this session authenticated by setting `session["authenticated"]`. class UserData:
Unless the session is authenticated, all requests result in a 403 FORBIDDEN. 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():
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
+9 -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:
@@ -122,10 +113,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()
+4 -3
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,7 +21,7 @@ def index():
@bp.get("/report") @bp.get("/report")
def read_report(): def read_report():
location = session.get("location", None) location = current_user.data.location
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": None if location is None else location["location_id"]
}).fetchall() }).fetchall()
+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()
+7
View File
@@ -71,3 +71,10 @@ th {
display: block; display: block;
width: 8em; width: 8em;
} }
details {
font-size: 0.8em;
}
.consumption-graph {
display: block;
height: 1em;
}
+12 -2
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("/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 +30,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>
+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>
+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