Move inventory to new repo
This commit is contained in:
commit
2b8820d4d7
5
CONTRIBUTORS.md
Normal file
5
CONTRIBUTORS.md
Normal file
@ -0,0 +1,5 @@
|
||||
Members site contributors
|
||||
=========================
|
||||
|
||||
* Madis Mägi
|
||||
* Lauri Võsandi
|
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@ -0,0 +1,15 @@
|
||||
FROM ubuntu:focal
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
curl ca-certificates iputils-ping \
|
||||
python3-ldap python3-pip python3-ldap \
|
||||
libjpeg-dev libturbojpeg0-dev \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
COPY requirements.txt ./
|
||||
#necessary hack for cffi
|
||||
RUN pip3 install cffi
|
||||
RUN pip3 install -r requirements.txt
|
||||
COPY inventory-app /app
|
||||
WORKDIR /app
|
||||
ENTRYPOINT /app/main.py
|
108
inventory-app/common.py
Normal file
108
inventory-app/common.py
Normal file
@ -0,0 +1,108 @@
|
||||
import collections.abc
|
||||
from functools import wraps
|
||||
|
||||
import requests
|
||||
from bson.objectid import ObjectId
|
||||
from flask import g, request
|
||||
from flask_wtf import FlaskForm
|
||||
from pymongo import MongoClient
|
||||
|
||||
import const
|
||||
|
||||
devenv = const.ENVIRONMENT_TYPE == "DEV"
|
||||
|
||||
db = MongoClient(const.MONGO_URI).get_default_database()
|
||||
|
||||
|
||||
class CustomForm(FlaskForm):
|
||||
# quite hacky
|
||||
def populate_dict(self, d):
|
||||
for name, field in self._fields.items():
|
||||
if name != "csrf_token":
|
||||
d[name] = field.data
|
||||
|
||||
|
||||
def inventory_fetch(owner=True, item_type=None):
|
||||
def wrapper(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
d = {
|
||||
"_id": ObjectId(kwargs.pop("item_id"))
|
||||
}
|
||||
if item_type:
|
||||
d["type"] = item_type
|
||||
if owner:
|
||||
d["inventory.owner.foreign_id"] = g.user["_id"]
|
||||
kwargs["item"] = db.inventory.find_one(d)
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return wrapper
|
||||
|
||||
def format_name(item):
|
||||
if item.get("locker"):
|
||||
return "Locker %d" % item.get("locker").get("number")
|
||||
elif item.get("mac"):
|
||||
return "Machine %s" % item.get("hostname", item.get("mac"))
|
||||
elif item.get("desk"):
|
||||
return "Desk %s" % item.get("desk").get("number")
|
||||
return item.get("name", "Unknown item")
|
||||
|
||||
def flatten(d, parent_key='', sep='.'):
|
||||
items = []
|
||||
for k, v in d.items():
|
||||
new_key = parent_key + sep + k if parent_key else k
|
||||
if isinstance(v, collections.abc.MutableMapping):
|
||||
items.extend(flatten(v, new_key, sep=sep).items())
|
||||
else:
|
||||
items.append((new_key, v))
|
||||
return dict(items)
|
||||
|
||||
def spam(msg):
|
||||
if devenv:
|
||||
print(msg)
|
||||
else:
|
||||
url = "https://hooks.slack.com/services/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"
|
||||
requests.post(url, json={"text": msg })
|
||||
|
||||
def build_query(base_query, fields=[], sort_fields={}):
|
||||
selectors = []
|
||||
q = base_query.copy()
|
||||
for attr, title, tp in fields:
|
||||
key = attr.replace(".", "_")
|
||||
if tp == list:
|
||||
val = request.args.getlist(key)
|
||||
val = list(map(str, val))
|
||||
else:
|
||||
val = request.args.get(key, type=tp)
|
||||
results = db.inventory.find(base_query).distinct(attr)
|
||||
if tp == ObjectId:
|
||||
results = sorted(results, key = lambda k: k["display_name"])
|
||||
elif tp != list:
|
||||
results = sorted(results)
|
||||
results = list(map(tp, results))
|
||||
selectors.append((key, title, results, val))
|
||||
if val:
|
||||
if tp == ObjectId:
|
||||
attr = attr + ".foreign_id"
|
||||
q[attr] = val
|
||||
elif tp == list and attr == "type":
|
||||
q[attr] = { "$nin" : val }
|
||||
elif tp == list:
|
||||
q[attr] = { "$in" : val }
|
||||
else:
|
||||
q[attr] = val
|
||||
|
||||
for s in selectors:
|
||||
if s[0] != "type":
|
||||
sort_fields[s[0]] = s[1]
|
||||
sort_field = request.args.get("sort_field", "", type=str)
|
||||
if sort_field not in sort_fields.keys():
|
||||
sort_field = "last_seen"
|
||||
if sort_field not in ["nic_vendor", "last_seen"]:
|
||||
sort_field_final = sort_field.replace("_", ".")
|
||||
else:
|
||||
sort_field_final = sort_field
|
||||
sort_direction = request.args.get("sort_direction", type=str)
|
||||
if sort_direction not in ["asc", "desc"]:
|
||||
sort_direction = "desc"
|
||||
return q, selectors, sort_field, sort_field_final, sort_direction
|
19
inventory-app/const.py
Normal file
19
inventory-app/const.py
Normal file
@ -0,0 +1,19 @@
|
||||
import os
|
||||
|
||||
def getenv_in(key, *vals):
|
||||
val = os.getenv(key)
|
||||
if val not in vals:
|
||||
raise ValueError("Got %s for %s, expected one of %s" % (repr(val), key, vals))
|
||||
return val
|
||||
|
||||
def file_exists(path):
|
||||
if not os.path.isfile(path):
|
||||
raise ValueError("Required file %s not found" % path)
|
||||
|
||||
ENVIRONMENT_TYPE = getenv_in("ENVIRONMENT_TYPE", "DEV", "PROD")
|
||||
|
||||
SECRET_KEY = os.environ["SECRET_KEY"]
|
||||
AWS_ENDPOINT_URL = os.environ["AWS_ENDPOINT_URL"]
|
||||
INVENTORY_ASSETS_BASE_URL = os.environ["INVENTORY_ASSETS_BASE_URL"]
|
||||
MONGO_URI = os.environ["MONGO_URI"]
|
||||
MEMBERS_HOST = os.environ["MEMBERS_HOST"]
|
105
inventory-app/decorators.py
Normal file
105
inventory-app/decorators.py
Normal file
@ -0,0 +1,105 @@
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
|
||||
from bson.objectid import ObjectId
|
||||
from flask import abort, g, make_response, redirect, request, session
|
||||
from pymongo import MongoClient
|
||||
|
||||
import const
|
||||
|
||||
db = MongoClient(const.MONGO_URI).get_default_database()
|
||||
|
||||
|
||||
def generate_password(length):
|
||||
letters = string.ascii_letters + string.digits + string.punctuation
|
||||
pw = secrets.choice(string.ascii_lowercase)
|
||||
pw += secrets.choice(string.ascii_uppercase)
|
||||
pw += secrets.choice(string.digits)
|
||||
pw += secrets.choice(string.punctuation)
|
||||
pw += "".join(secrets.choice(letters) for i in range(length - 4))
|
||||
return "".join(secrets.SystemRandom().sample(pw, length))
|
||||
|
||||
def check_login():
|
||||
token = db.token.find_one({"cookie": request.cookies.get("LOGINLINKCOOKIE", "")})
|
||||
if not token:
|
||||
cookie = generate_password(50)
|
||||
res = db.token.update_one({
|
||||
"token": request.args.get("token"),
|
||||
"cookie": {"$exists": False}
|
||||
}, {
|
||||
"$set": {
|
||||
"used": datetime.utcnow(),
|
||||
"cookie": cookie
|
||||
}
|
||||
})
|
||||
if res.matched_count >= 1:
|
||||
return (1, cookie)
|
||||
else:
|
||||
return (2, cookie)
|
||||
|
||||
db.token.update_one({
|
||||
"_id": token["_id"]
|
||||
}, {
|
||||
"$set": {
|
||||
"last_seen": datetime.utcnow(),
|
||||
"user_agent": request.headers.get("User-Agent"),
|
||||
"remote_addr": request.remote_addr
|
||||
}
|
||||
})
|
||||
|
||||
g.user = db.member.find_one({"_id": ObjectId(token["member_id"])})
|
||||
g.token = token
|
||||
return (0, None);
|
||||
|
||||
def has_login():
|
||||
r, _ = check_login()
|
||||
return r == 0
|
||||
|
||||
def login_redirect():
|
||||
session['target_path'] = request.path
|
||||
return redirect("/login")
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
r, cookie = check_login()
|
||||
if r == 1:
|
||||
resp = make_response()
|
||||
resp.set_cookie("LOGINLINKCOOKIE", cookie)
|
||||
resp.headers["location"] = request.path
|
||||
return resp, 302
|
||||
elif r == 2:
|
||||
return login_redirect()
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def rget(d, attr):
|
||||
if not d:
|
||||
return None
|
||||
if "." in attr:
|
||||
pre, post = attr.split(".", 1)
|
||||
return rget(d.get(pre), post)
|
||||
else:
|
||||
return d.get(attr)
|
||||
|
||||
|
||||
def required(path, msg=None, status=403):
|
||||
def ff(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not rget(g.user, path):
|
||||
abort(status, description=msg)
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return ff
|
||||
|
||||
|
||||
def board_member_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
_f = required("access.board", "K-SPACE MTÜ board member required")
|
||||
return _f(f)(*args, **kwargs)
|
||||
return decorated_function
|
481
inventory-app/inventory.py
Normal file
481
inventory-app/inventory.py
Normal file
@ -0,0 +1,481 @@
|
||||
import boto3
|
||||
import pymongo
|
||||
from bson.objectid import ObjectId
|
||||
from flask import Blueprint, abort, g, make_response, redirect, render_template, request
|
||||
from jpegtran import JPEGImage
|
||||
from pymongo import MongoClient
|
||||
from werkzeug.utils import secure_filename
|
||||
from wtforms import BooleanField, SelectField, StringField, validators
|
||||
from wtforms.fields import FormField, TextAreaField
|
||||
from wtforms.form import Form
|
||||
from wtforms.validators import Length
|
||||
|
||||
import const
|
||||
from common import CustomForm, build_query, flatten, format_name, spam
|
||||
from decorators import has_login, login_redirect, login_required
|
||||
|
||||
page_inventory = Blueprint("inventory", __name__)
|
||||
db = MongoClient(const.MONGO_URI).get_default_database()
|
||||
|
||||
@page_inventory.route("/m/inventory/<item_id>/view")
|
||||
def view_inventory_view(item_id):
|
||||
template = "inventory_view.html"
|
||||
item = db.inventory.find_one({ "_id": ObjectId(item_id) })
|
||||
if not has_login():
|
||||
if not item["inventory"].get("public"):
|
||||
return login_redirect()
|
||||
template = "inventory_view_public.html"
|
||||
base_url = const.INVENTORY_ASSETS_BASE_URL
|
||||
photo_url = "%s/kspace-inventory/%s" % (base_url, item_id)
|
||||
return render_template(template , **locals())
|
||||
|
||||
def fetch_members_select(existing_id=None):
|
||||
q = [{"enabled": True}]
|
||||
if existing_id:
|
||||
q.append({"_id": ObjectId(existing_id)})
|
||||
objs = db.member.find(filter = { "$or": q }, projection = {
|
||||
"full_name": 1
|
||||
}).sort("full_name", 1)
|
||||
choices = [(None, None)]
|
||||
for obj in objs:
|
||||
choices.append((obj["_id"], obj["full_name"]))
|
||||
choices = list(set(choices))
|
||||
return choices
|
||||
|
||||
def fetch_type_select():
|
||||
objs = db.inventory.distinct("type")
|
||||
choices = set()
|
||||
choices.add("")
|
||||
for obj in objs:
|
||||
choices.add(obj)
|
||||
return choices
|
||||
|
||||
def setup_user_select(select_field, name_fields):
|
||||
existing_id = None
|
||||
if select_field.data and select_field.data != 'None':
|
||||
existing_id = select_field.data
|
||||
select_field.choices = fetch_members_select(existing_id)
|
||||
if existing_id:
|
||||
values = dict(select_field.choices)
|
||||
value = values.get(ObjectId(existing_id))
|
||||
for name_field in name_fields:
|
||||
name_field.data = value
|
||||
|
||||
def setup_default_owner(select_field):
|
||||
c = select_field.choices
|
||||
d = [i for i in c if i[1] == "K-SPACE MTÜ"]
|
||||
if len(d) < 1:
|
||||
return
|
||||
dv = d[0] or None
|
||||
select_field.default = dv[0]
|
||||
select_field.process([])
|
||||
|
||||
def member_select_coerce(x):
|
||||
x = str(x)
|
||||
if x and x != "None":
|
||||
return ObjectId(x)
|
||||
else:
|
||||
return None
|
||||
|
||||
class MemberForm(Form):
|
||||
label = None
|
||||
foreign_id = SelectField(choices=[], coerce=member_select_coerce)
|
||||
display_name = StringField()
|
||||
def __init__(self, label=None, *args, **kwargs):
|
||||
super(MemberForm, self).__init__(*args, **kwargs)
|
||||
self.label = label
|
||||
setup_user_select(self.foreign_id, [self.display_name])
|
||||
|
||||
class InventoryForm(Form):
|
||||
owner = FormField(MemberForm, label="Owner")
|
||||
user = FormField(MemberForm, label="Current User")
|
||||
usable = BooleanField("Usable")
|
||||
public = BooleanField("Public")
|
||||
|
||||
class HardwareForm(Form):
|
||||
serial = StringField("Serial Number")
|
||||
product = StringField("Product")
|
||||
vendor = StringField("Vendor")
|
||||
|
||||
class ShortenerForm(Form):
|
||||
slug = StringField("Slug", [Length(max=4)], [lambda x: x or None])
|
||||
url = StringField("URL", [], [lambda x: x or None])
|
||||
|
||||
class InventoryItemForm(CustomForm):
|
||||
type = SelectField("Type", choices=fetch_type_select())
|
||||
name = StringField('Name')
|
||||
issue_tracker = StringField('Issue Tracker')
|
||||
comment = StringField('Comment')
|
||||
inventory = FormField(InventoryForm)
|
||||
hardware = FormField(HardwareForm)
|
||||
shortener = FormField(ShortenerForm)
|
||||
_description_placeholder = "Insert Markdown here."
|
||||
description = TextAreaField('Description', [validators.optional(), validators.length(max=3000)], render_kw={"placeholder": _description_placeholder})
|
||||
def setup_defaults_add(self):
|
||||
setup_default_owner(self.inventory.form.owner.form.foreign_id)
|
||||
self.inventory.form.public.render_kw = {"checked": "checked"}
|
||||
self.inventory.form.public.data = "y"
|
||||
|
||||
|
||||
@page_inventory.route("/m/inventory/<item_id>/edit", methods=['GET'])
|
||||
@page_inventory.route("/m/inventory/<item_id>/edit-by-slug/<slug>", methods=['GET'])
|
||||
@page_inventory.route("/m/inventory/add", methods=['GET'])
|
||||
@page_inventory.route("/m/inventory/add-by-slug/<slug>", methods=['GET'])
|
||||
@page_inventory.route("/m/inventory/<clone_item_id>/clone", methods=['GET'])
|
||||
@page_inventory.route("/m/inventory/<clone_item_id>/clone-by-slug/<slug>", methods=['GET'])
|
||||
@login_required
|
||||
def view_inventory_edit(item_id=None, slug=None, clone_item_id=None):
|
||||
item = None
|
||||
if item_id:
|
||||
item = db.inventory.find_one({ "_id": ObjectId(item_id) })
|
||||
form = InventoryItemForm(**item)
|
||||
elif clone_item_id:
|
||||
item = db.inventory.find_one({ "_id": ObjectId(clone_item_id) })
|
||||
item.pop("_id", None)
|
||||
item.pop("shortener", None)
|
||||
item.pop("name", None)
|
||||
if "hardware" in item:
|
||||
item["hardware"].pop("serial", None)
|
||||
form = InventoryItemForm(**item)
|
||||
else:
|
||||
form = InventoryItemForm()
|
||||
form.setup_defaults_add()
|
||||
if slug:
|
||||
form.shortener.slug.data = slug
|
||||
all_tags = db.inventory.distinct("tags")
|
||||
item_tags = []
|
||||
if item and item.get("tags"):
|
||||
item_tags = item["tags"]
|
||||
return render_template("inventory_edit.html", **locals())
|
||||
|
||||
@page_inventory.route("/m/inventory/<item_id>/edit", methods=['POST'])
|
||||
@page_inventory.route("/m/inventory/<item_id>/edit-by-slug/<slug>", methods=['POST'])
|
||||
@page_inventory.route("/m/inventory/add", methods=['POST'])
|
||||
@page_inventory.route("/m/inventory/add-by-slug/<slug>", methods=['POST'])
|
||||
@page_inventory.route("/m/inventory/<clone_item_id>/clone", methods=['POST'])
|
||||
@page_inventory.route("/m/inventory/<clone_item_id>/clone-by-slug/<slug>", methods=['POST'])
|
||||
@login_required
|
||||
def save_inventory_item(item_id=None, **_):
|
||||
form = InventoryItemForm(request.form)
|
||||
if not form.validate_on_submit():
|
||||
has_errors = True
|
||||
return render_template("inventory_edit.html", **locals())
|
||||
d = {}
|
||||
form.populate_dict(d)
|
||||
d['tags'] = list(set(request.form.getlist('tags[]')))
|
||||
custom_errors = {}
|
||||
try:
|
||||
if item_id:
|
||||
df = flatten(d)
|
||||
r = {}
|
||||
for k, v in dict(df).items():
|
||||
if k.startswith("shortener.") and not v:
|
||||
df.pop(k)
|
||||
r[k] = v
|
||||
if k.startswith("inventory.user.") and not v:
|
||||
df.pop(k)
|
||||
r["inventory.user"] = ""
|
||||
if k.startswith("inventory.owner.") and not v:
|
||||
df.pop(k)
|
||||
r["inventory.owner"] = ""
|
||||
db.inventory.update_one({
|
||||
"_id": ObjectId(item_id)
|
||||
}, {
|
||||
"$set": df,
|
||||
"$unset": r
|
||||
})
|
||||
else:
|
||||
d["inventory"]["managed"] = True
|
||||
if "shortener" in d:
|
||||
if not d["shortener"]["slug"]:
|
||||
d.pop("shortener")
|
||||
if "user" in d["inventory"]:
|
||||
if not d["inventory"]["user"]["foreign_id"]:
|
||||
d["inventory"].pop("user")
|
||||
if "owner" in d["inventory"]:
|
||||
if not d["inventory"]["owner"]["foreign_id"]:
|
||||
d["inventory"].pop("owner")
|
||||
item_id = db.inventory.insert_one(d).inserted_id
|
||||
except pymongo.errors.DuplicateKeyError:
|
||||
has_errors = True
|
||||
slug_error = "Slug %s already is assigned" % d["shortener"]["slug"]
|
||||
custom_errors["Shortener"] = slug_error
|
||||
return render_template("inventory_edit.html", **locals())
|
||||
return redirect("/m/inventory/%s/view" % item_id)
|
||||
|
||||
@page_inventory.route("/m/inventory/add-slug/<slug>", methods=['GET'])
|
||||
@login_required
|
||||
def add_inventory_slug(slug):
|
||||
slug_item = db.inventory.find_one({ "shortener.slug": slug })
|
||||
if slug_item:
|
||||
return redirect("/m/inventory/%s/view" % slug_item["_id"])
|
||||
return render_template("inventory_add_slug.html", **locals())
|
||||
|
||||
|
||||
def is_image_ext(filename):
|
||||
return '.' in filename and \
|
||||
filename.rsplit('.', 1)[1].lower() in ["jpg", "jpeg"]
|
||||
|
||||
def get_bucket():
|
||||
return boto3.client('s3',
|
||||
endpoint_url=const.AWS_ENDPOINT_URL,
|
||||
config=boto3.session.Config(signature_version='s3v4'),
|
||||
region_name='us-east-1')
|
||||
|
||||
@page_inventory.route("/inventory/<item_id>/upload-photo", methods=["POST"])
|
||||
@login_required
|
||||
def upload_photo(item_id):
|
||||
item = db.inventory.find_one(filter = { "_id": ObjectId(item_id) }, projection = { "thumbs": 1 })
|
||||
if not item:
|
||||
return "Item not found", 404
|
||||
if "file" not in request.files:
|
||||
return "No file part", 400
|
||||
file = request.files["file"]
|
||||
if not file.filename:
|
||||
return "No selected file", 400
|
||||
if file and is_image_ext(secure_filename(file.filename)):
|
||||
try:
|
||||
file.seek(0)
|
||||
img = JPEGImage(blob=file.read())
|
||||
except:
|
||||
return "Not a valid JPEG", 400
|
||||
if min(img.width, img.height) < 576:
|
||||
return "Image must have smallest dimension of at least 576px", 400
|
||||
bucket = get_bucket()
|
||||
file.seek(0)
|
||||
bucket.upload_fileobj(file, 'kspace-inventory', item_id)
|
||||
|
||||
db.inventory.update_one({ "_id": ObjectId(item_id) }, {"$set": {"has_photo": True}})
|
||||
delete_thumbs(item)
|
||||
return redirect("/m/inventory/%s/view" % item_id)
|
||||
else:
|
||||
return "File is not valid", 400
|
||||
|
||||
|
||||
@page_inventory.route("/m/photo/<item_id>/<dimension>")
|
||||
def get_scaled_photo(item_id=None, dimension=0, retry=2):
|
||||
dimension = int(dimension) #why
|
||||
if retry <= 0:
|
||||
return "", 500
|
||||
if dimension not in [240, 480, 576, 600]:
|
||||
return abort(404)
|
||||
item = db.inventory.find_one(filter = { "_id": ObjectId(item_id) }, projection = { "thumbs": 1 })
|
||||
if not item:
|
||||
return abort(404)
|
||||
if item.get("thumbs", {}).get(str(dimension)):
|
||||
thumb = item["thumbs"][str(dimension)]
|
||||
img = get_item(thumb["name"])
|
||||
if not img:
|
||||
delete_thumbs(item)
|
||||
return get_scaled_photo(item_id, dimension, retry - 1)
|
||||
img = JPEGImage(blob=img)
|
||||
else:
|
||||
make_thumb(item, dimension)
|
||||
return get_scaled_photo(item_id, dimension, retry - 1)
|
||||
|
||||
response = make_response(img.as_blob())
|
||||
response.headers.set('Content-Type', 'image/jpeg')
|
||||
response.cache_control.public = True
|
||||
response.cache_control.immutable = True
|
||||
response.cache_control.max_age = 31536000
|
||||
return response
|
||||
|
||||
def get_item(key):
|
||||
try:
|
||||
bucket = get_bucket()
|
||||
r = bucket.get_object(Bucket='kspace-inventory', Key=key)
|
||||
return r['Body'].read()
|
||||
except:
|
||||
return None
|
||||
|
||||
def make_thumb(item, dimension):
|
||||
img = get_item(str(item["_id"]))
|
||||
img = scale_image(img, dimension)
|
||||
bucket = get_bucket()
|
||||
thumb_name = "thumb_%s_%d" % (item["_id"], dimension)
|
||||
bucket.put_object(Body=img.as_blob(), Bucket='kspace-inventory', Key=thumb_name)
|
||||
db.inventory.update_one({
|
||||
"_id": ObjectId(item["_id"]),
|
||||
}, {
|
||||
"$set": {
|
||||
"thumbs.%d.name" % dimension : thumb_name,
|
||||
},
|
||||
})
|
||||
return img
|
||||
|
||||
def scale_image(img, dimension):
|
||||
img = JPEGImage(blob=img)
|
||||
if img.exif_orientation:
|
||||
img = img.exif_autotransform()
|
||||
|
||||
if img.width < img.height:
|
||||
img = img.downscale(dimension, img.height * dimension // img.width, 100)
|
||||
else:
|
||||
img = img.downscale(img.width * dimension // img.height, dimension, 100)
|
||||
|
||||
ratio = 4 / 3
|
||||
crop_height = min(img.width / ratio, img.height)
|
||||
crop_width = crop_height * ratio
|
||||
crop_x = int((img.width - crop_width) / 2) & ~7
|
||||
crop_y = int((img.height - crop_height) / 2) & ~7
|
||||
return img.crop(crop_x, crop_y, int(crop_width), int(crop_height))
|
||||
|
||||
def delete_thumbs(item):
|
||||
bucket = get_bucket()
|
||||
for _, thumb in item.get("thumbs", {}).items():
|
||||
if thumb.get("name"):
|
||||
bucket.delete_object(Bucket='kspace-inventory', Key=thumb["name"])
|
||||
db.inventory.update_one({
|
||||
"_id": ObjectId(item["_id"])
|
||||
}, {
|
||||
"$unset": {
|
||||
"thumbs": ""
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@page_inventory.route("/m/inventory/assign-slug/<slug>")
|
||||
@page_inventory.route("/m/inventory/clone-with-slug/<slug>")
|
||||
@page_inventory.route("/m/inventory")
|
||||
def view_inventory(slug=None):
|
||||
user = request.args.get("user", type=ObjectId)
|
||||
owner = request.args.get("owner", type=ObjectId)
|
||||
grid = request.args.get("grid", type=str) == "true"
|
||||
owner_disabled = request.args.get("owner_disabled", type=str) == "true"
|
||||
user_disabled = request.args.get("user_disabled", type=str) == "true"
|
||||
sort_fields = {
|
||||
"last_seen": "Last seen",
|
||||
"name": "Name",
|
||||
"type": "Inventory type",
|
||||
}
|
||||
fields = [
|
||||
("type", "Exclude type", list),
|
||||
("tags", "Tags", list),
|
||||
]
|
||||
q = {"type": {"$ne": "token"}}
|
||||
template = "inventory.html"
|
||||
public_view = False
|
||||
if not has_login():
|
||||
q.update({"inventory.public": True})
|
||||
template = "inventory_public.html"
|
||||
public_view = True
|
||||
else:
|
||||
fields.append(("inventory.owner", "Owner", ObjectId))
|
||||
fields.append(("inventory.user", "User", ObjectId))
|
||||
if slug and not public_view:
|
||||
template = "inventory_pick.html"
|
||||
if request.path.startswith("/m/inventory/clone-with-slug"):
|
||||
action_path = "clone-by-slug"
|
||||
action_label = "Clone"
|
||||
action_help = "Cloning item to new slug"
|
||||
pick = True
|
||||
elif request.path.startswith("/m/inventory/assign-slug"):
|
||||
q.update({"shortener.slug": None})
|
||||
action_path = "edit-by-slug"
|
||||
action_label = "Assign slug"
|
||||
action_help = "Assigning slug"
|
||||
pick = True
|
||||
if grid:
|
||||
template = "inventory_grid.html"
|
||||
if user:
|
||||
q["inventory.user.foreign_id"] = user
|
||||
if owner:
|
||||
q["inventory.owner.foreign_id"] = owner
|
||||
|
||||
q2 = {"type": {"$ne": "token"}}
|
||||
if not public_view and owner_disabled:
|
||||
q2["Owner.enabled"] = False
|
||||
if not public_view and user_disabled:
|
||||
q2["User.enabled"] = False
|
||||
q, selectors, sort_field, sort_field_final, sort_direction = build_query(dict(q), fields, sort_fields)
|
||||
items = db.inventory.aggregate([
|
||||
{ "$match": q },
|
||||
{
|
||||
"$lookup": {
|
||||
"from": 'member',
|
||||
"localField": 'inventory.owner.foreign_id',
|
||||
"foreignField": '_id',
|
||||
"as": 'Owner'
|
||||
}
|
||||
},
|
||||
{
|
||||
"$lookup": {
|
||||
"from": 'member',
|
||||
"localField": 'inventory.user.foreign_id',
|
||||
"foreignField": '_id',
|
||||
"as": 'User'
|
||||
}
|
||||
},
|
||||
{ "$match": q2 },
|
||||
{ "$sort": { sort_field_final : 1 if sort_direction == "asc" else -1 } }
|
||||
])
|
||||
|
||||
return render_template(template, **locals())
|
||||
|
||||
|
||||
@page_inventory.route("/m/inventory/<item_id>/claim", methods=["POST"])
|
||||
@login_required
|
||||
def view_inventory_claim(item_id):
|
||||
db.inventory.update_one({
|
||||
"_id": ObjectId(item_id),
|
||||
"inventory.owner.foreign_id": None
|
||||
}, {
|
||||
"$set": {
|
||||
"inventory.owner.foreign_id": ObjectId(g.user["_id"]),
|
||||
"inventory.owner.display_name": g.user["full_name"],
|
||||
},
|
||||
})
|
||||
return redirect("/m/user/%s" % g.user["_id"])
|
||||
|
||||
|
||||
@page_inventory.route("/m/inventory/<item_id>/use", methods=["POST"])
|
||||
@login_required
|
||||
def view_inventory_use(item_id):
|
||||
item = db.inventory.find_one({
|
||||
"_id": ObjectId(item_id),
|
||||
"inventory.usable": True,
|
||||
"inventory.user.foreign_id": None
|
||||
})
|
||||
if not item:
|
||||
return abort(404)
|
||||
|
||||
db.inventory.update_one({
|
||||
"_id": ObjectId(item["_id"])
|
||||
}, {
|
||||
"$set": {
|
||||
"inventory.user.foreign_id": ObjectId(g.user["_id"]),
|
||||
"inventory.user.display_name": g.user["full_name"],
|
||||
},
|
||||
})
|
||||
name = format_name(item)
|
||||
msg = "%s has started using %s" % (g.user["full_name"], name)
|
||||
if item.get("shortener") and item["shortener"].get("slug"):
|
||||
msg += ("\nk6.ee/%s" % item["shortener"]["slug"])
|
||||
spam(msg)
|
||||
return redirect("/m/user/%s" % g.user["_id"])
|
||||
|
||||
|
||||
@page_inventory.route("/m/inventory/<item_id>/vacate", methods=["POST"])
|
||||
@login_required
|
||||
def view_inventory_vacate(item_id):
|
||||
item = db.inventory.find_one({
|
||||
"_id": ObjectId(item_id),
|
||||
"inventory.usable": True,
|
||||
"inventory.user.foreign_id": ObjectId(g.user["_id"])
|
||||
})
|
||||
if not item:
|
||||
return abort(404)
|
||||
|
||||
db.inventory.update_one({
|
||||
"_id": ObjectId(item["_id"])
|
||||
}, {
|
||||
"$unset": {
|
||||
"inventory.user": ""
|
||||
},
|
||||
})
|
||||
name = format_name(item)
|
||||
msg = "%s has stopped using %s" % (g.user["full_name"], name)
|
||||
if item.get("shortener") and item["shortener"].get("slug"):
|
||||
msg += ("\nk6.ee/%s" % item["shortener"]["slug"])
|
||||
spam(msg)
|
||||
return redirect("/m/user/%s" % g.user["_id"])
|
257
inventory-app/main.py
Executable file
257
inventory-app/main.py
Executable file
@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import unicodedata
|
||||
import urllib
|
||||
from collections import Counter
|
||||
from configparser import ConfigParser
|
||||
from datetime import datetime, timedelta
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from functools import wraps
|
||||
from time import sleep
|
||||
|
||||
import bleach
|
||||
import flask
|
||||
import jinja2
|
||||
import ldap
|
||||
import markdown
|
||||
import pymongo
|
||||
import requests
|
||||
import safe
|
||||
from bson.objectid import ObjectId
|
||||
from flask import Flask, abort, g, make_response, redirect, render_template, request, session
|
||||
from flask_wtf import FlaskForm, RecaptchaField
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from ldap import modlist
|
||||
from markupsafe import Markup
|
||||
from prometheus_flask_exporter import PrometheusMetrics
|
||||
from pymongo import MongoClient
|
||||
from werkzeug import exceptions
|
||||
from wtforms import (
|
||||
BooleanField,
|
||||
PasswordField,
|
||||
SelectField,
|
||||
SelectMultipleField,
|
||||
StringField,
|
||||
ValidationError,
|
||||
validators,
|
||||
widgets,
|
||||
)
|
||||
from wtforms.fields import FormField
|
||||
from wtforms.fields import DateField, EmailField
|
||||
from wtforms.form import Form
|
||||
from wtforms.validators import DataRequired
|
||||
|
||||
import const
|
||||
from common import CustomForm, devenv, flatten, format_name, spam
|
||||
from decorators import board_member_required, generate_password, login_required
|
||||
from inventory import page_inventory
|
||||
|
||||
def render_markdown(text):
|
||||
if not text:
|
||||
return ""
|
||||
cleaned = bleach.clean(text)
|
||||
md = markdown.markdown(cleaned)
|
||||
return Markup(md)
|
||||
|
||||
def render_timeago(dt):
|
||||
if not dt:
|
||||
return ""
|
||||
return Markup("<time class=\"timeago\" datetime=\"%s+0000\">%s</time>" % (dt, dt))
|
||||
|
||||
def render_last_seen(machine, stale_minutes=30):
|
||||
stale_timestamp = datetime.utcnow() - timedelta(minutes=stale_minutes)
|
||||
if machine and machine.get("last_seen"):
|
||||
if machine.get("last_seen") < stale_timestamp:
|
||||
return Markup("opacity: 25%;")
|
||||
|
||||
def render_owner_link(item):
|
||||
owner = item.get("inventory", {}).get("owner")
|
||||
if owner and owner.get("foreign_id") and owner.get("display_name"):
|
||||
return Markup("<a href=\"/m/user/%s\">%s</a>" % (owner["foreign_id"], owner["display_name"]))
|
||||
else:
|
||||
return Markup("<form method=\"post\" action=\"/m/inventory/%s/claim\"><button type=\"submit\" class=\"waves-effect waves-light btn\">Mine!</button></form>" % (item["_id"]))
|
||||
|
||||
def render_user_link(item):
|
||||
user = item.get("inventory", {}).get("user")
|
||||
if user and user.get("foreign_id") and user.get("display_name"):
|
||||
return Markup("<a href=\"/m/user/%s\">%s</a>" % (user["foreign_id"], user["display_name"]))
|
||||
elif item.get("inventory", {}).get("usable"):
|
||||
return Markup("<form method=\"post\" action=\"/m/inventory/%s/use\"><button type=\"submit\" class=\"waves-effect waves-light btn\">I use it now!</button></form>" % (item["_id"]))
|
||||
else:
|
||||
return ""
|
||||
|
||||
def is_list(value):
|
||||
return isinstance(value, list)
|
||||
|
||||
jinja2.filters.FILTERS['format_name'] = format_name
|
||||
jinja2.filters.FILTERS['markdown'] = render_markdown
|
||||
jinja2.filters.FILTERS['timeago'] = render_timeago
|
||||
jinja2.filters.FILTERS['last_seen'] = render_last_seen
|
||||
jinja2.filters.FILTERS['owner_link'] = render_owner_link
|
||||
jinja2.filters.FILTERS['user_link'] = render_user_link
|
||||
jinja2.filters.FILTERS['is_list'] = is_list
|
||||
jinja2.filters.FILTERS['quote_plus'] = lambda u: urllib.parse.quote_plus(u)
|
||||
env = Environment(loader=FileSystemLoader('templates/'))
|
||||
|
||||
|
||||
class ReverseProxied(object):
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
scheme = environ.get('HTTP_X_FORWARDED_PROTO')
|
||||
if scheme and scheme in ['http', 'https']:
|
||||
environ['wsgi.url_scheme'] = scheme
|
||||
return self.app(environ, start_response)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.wsgi_app = ReverseProxied(app.wsgi_app)
|
||||
app.register_blueprint(page_inventory)
|
||||
metrics = PrometheusMetrics(app, group_by="path")
|
||||
|
||||
app.config['SECRET_KEY'] = const.SECRET_KEY
|
||||
|
||||
mongoclient = MongoClient(const.MONGO_URI)
|
||||
mongodb = mongoclient.get_default_database()
|
||||
mongodb.member.create_index("ad.username", sparse=True, unique=True)
|
||||
mongodb.inventory.create_index("shortener.slug", sparse=True, unique=True)
|
||||
mongodb.inventory.create_index("token.uid_hash", sparse=True, unique=True)
|
||||
#mongodb.inventory.create_index("token.uid_hash", unique=True)
|
||||
|
||||
CATEGORY_COLORS = (
|
||||
('membership-fee', '#acc236'),
|
||||
('rent', '#f53794'),
|
||||
('accounting-fees', '#f67019'),
|
||||
('office-supplies', '#58595b'),
|
||||
('server-room', '#166a8f'),
|
||||
('training', '#00a950'),
|
||||
('membership-fee-company', '#4dc9f6'),
|
||||
('snack-machine', '#8549ba'),
|
||||
('other', '#537bc4'),
|
||||
)
|
||||
|
||||
INCOME_CATEGORIES = "membership-fee", "workshop"
|
||||
|
||||
cp = ConfigParser()
|
||||
cp.read("/config/provision.conf")
|
||||
|
||||
PROVISION_CONFIGS = {}
|
||||
for section in cp.sections():
|
||||
if not cp.getint(section, "enabled"):
|
||||
continue
|
||||
PROVISION_CONFIGS[section] = {
|
||||
"entrypoint": cp.get(section, "entrypoint"),
|
||||
"admin": cp.get(section, "admin"),
|
||||
"type": cp.get(section, "type"),
|
||||
"comment": cp.get(section, "comment"),
|
||||
"username": cp.get(section, "username"),
|
||||
"password": cp.get(section, "password"),
|
||||
"port": cp.get(section, "port"),
|
||||
}
|
||||
|
||||
|
||||
def dev_only(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not devenv:
|
||||
return abort(404)
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_context():
|
||||
return dict(devenv=devenv, inventory_assets_base_url=const.INVENTORY_ASSETS_BASE_URL, members_host=const.MEMBERS_HOST)
|
||||
|
||||
def name_check(form, field):
|
||||
if field.data != field.data.strip():
|
||||
raise ValidationError("Name must not contain leading or trailing space")
|
||||
if any(c.isdigit() for c in field.data):
|
||||
raise ValidationError("Name must not contain numbers")
|
||||
|
||||
@dev_only
|
||||
@app.route("/dev_login")
|
||||
@app.route("/dev_login/<full_name>")
|
||||
def dev_login(full_name=None):
|
||||
allowed_users = ["Mickey Mouse", "Donald Duck"]
|
||||
if not full_name or full_name not in allowed_users:
|
||||
full_name = "Mickey Mouse"
|
||||
member = mongodb.member.find_one({"full_name": full_name})
|
||||
|
||||
token = generate_password(20)
|
||||
d = {
|
||||
"member_id": member["_id"],
|
||||
"full_name": member["full_name"],
|
||||
"token": token,
|
||||
"method": "link"
|
||||
}
|
||||
mongodb.token.insert_one(d)
|
||||
url = "%sm/profile?token=%s" % (request.url_root, urllib.parse.quote_plus(token))
|
||||
return redirect(url, code=302)
|
||||
|
||||
@app.route("/m/user/<member_id>/login")
|
||||
@metrics.do_not_track()
|
||||
@login_required
|
||||
def view_send_login_link(member_id):
|
||||
user = g.user
|
||||
member = mongodb.member.find_one({"_id": ObjectId(member_id), "enabled": True})
|
||||
if not member:
|
||||
raise
|
||||
send_login_link(member,
|
||||
provider = g.user["full_name"])
|
||||
return redirect("/m/user/%s" % member_id)
|
||||
|
||||
|
||||
class LoginLinkForm(CustomForm):
|
||||
email = EmailField('Email address', validators = [validators.DataRequired(), validators.Email()])
|
||||
recaptcha = RecaptchaField()
|
||||
|
||||
@app.route("/login/address", methods=["POST"])
|
||||
def request_login_link():
|
||||
form = LoginLinkForm(request.form)
|
||||
if not form.validate_on_submit():
|
||||
return abort(400)
|
||||
sleep(secrets.SystemRandom().randint(10, 40))
|
||||
member = mongodb.member.find_one({"mail": form.email.data, "enabled": True})
|
||||
if member:
|
||||
send_login_link(member)
|
||||
return render_template("login_link_request.html", **locals())
|
||||
|
||||
@app.route("/login")
|
||||
def view_login():
|
||||
form = LoginLinkForm()
|
||||
return render_template("login.html", **locals())
|
||||
|
||||
|
||||
@app.route("/login/authelia")
|
||||
def view_login_authelia():
|
||||
redirect_location = session.pop('target_path', "/m/profile")
|
||||
username = request.headers.get("Remote-User")
|
||||
if not username:
|
||||
raise ValueError("Ding dong")
|
||||
|
||||
member = mongodb.member.find_one({"ad.username": username, "enabled":True})
|
||||
if not member:
|
||||
return render_template("login_error.html")
|
||||
d = {
|
||||
"member_id": member["_id"],
|
||||
"full_name": member["full_name"],
|
||||
"method": "authelia",
|
||||
"cookie": generate_password(50)
|
||||
}
|
||||
|
||||
mongodb.token.insert_one(d)
|
||||
resp = make_response()
|
||||
resp.set_cookie("LOGINLINKCOOKIE", d["cookie"])
|
||||
resp.headers['location'] = redirect_location
|
||||
return resp, 302
|
||||
|
||||
class MultiCheckboxField(SelectMultipleField):
|
||||
widget = widgets.ListWidget(prefix_label=False)
|
||||
option_widget = widgets.CheckboxInput()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=devenv, host='::')
|
106
inventory-app/templates/base.html
Normal file
106
inventory-app/templates/base.html
Normal file
@ -0,0 +1,106 @@
|
||||
<html>
|
||||
<head>
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <!-- ompiled and minified CSS -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
|
||||
<style>
|
||||
{% if devenv %}
|
||||
body {
|
||||
background-color: lightgreen;
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
.table-header-rotated {
|
||||
border-collapse: collapse;
|
||||
.csstransforms & td {
|
||||
width: 30px;
|
||||
}
|
||||
.no-csstransforms & th {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
td {
|
||||
text-align: center;
|
||||
padding: 10px 5px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.csstransforms & th.rotate {
|
||||
height: 140px;
|
||||
white-space: nowrap;
|
||||
// Firefox needs the extra DIV for some reason, otherwise the text disappears if you rotate
|
||||
> div {
|
||||
transform:
|
||||
// Magic Numbers
|
||||
translate(25px, 51px)
|
||||
// 45 is really 360-45
|
||||
rotate(315deg);
|
||||
width: 30px;
|
||||
}
|
||||
> div > span {
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
}
|
||||
th.row-header {
|
||||
padding: 0 10px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.auto-height * {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.placeholder-dark *::placeholder {
|
||||
color: rgb(66, 73, 73);
|
||||
}
|
||||
|
||||
.line-clamp {
|
||||
display: -webkit-box !important;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<script
|
||||
src="https://code.jquery.com/jquery-3.5.1.min.js"
|
||||
integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0="
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="//timeago.yarp.com/jquery.timeago.js" type="text/javascript"></script>
|
||||
<nav>
|
||||
<div class="nav-wrapper">
|
||||
<a href="#" data-target="mobile-demo" class="sidenav-trigger"><i class="material-icons">menu</i></a>
|
||||
<ul id="nav-mobile" class="left hide-on-med-and-down">
|
||||
{% include "menu.html" %}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
<ul class="sidenav" id="mobile-demo">
|
||||
{% include "menu.html" %}
|
||||
</ul>
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
$("time.timeago").timeago();
|
||||
var elems = document.querySelectorAll('.tooltipped');
|
||||
var instances = M.Tooltip.init(elems, {});
|
||||
|
||||
var elems = document.querySelectorAll('.sidenav');
|
||||
var instances = M.Sidenav.init(elems, {});
|
||||
|
||||
var elems = document.querySelectorAll('select');
|
||||
var instances = M.FormSelect.init(elems, {});
|
||||
|
||||
var elems = document.querySelectorAll('.materialboxed');
|
||||
var instances = M.Materialbox.init(elems, {});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
38
inventory-app/templates/inventory.html
Normal file
38
inventory-app/templates/inventory.html
Normal file
@ -0,0 +1,38 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
{% include "inventory_filter.html" %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Slug</th>
|
||||
<th>Public</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Owner</th>
|
||||
<th>User</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if item.shortener %}
|
||||
<a href="http://k6.ee/{{ item.shortener.slug }}">{{ item.shortener.slug }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if item.inventory.public %}<i class="material-icons">check_circle</i>{% else %} {% endif %}</td>
|
||||
<td><a href="/m/inventory/{{ item._id }}/view">{{ item | format_name }} {{ item.comment }}</a></td>
|
||||
<td>{{ item.type }}</td>
|
||||
<td>{{ item | owner_link}}</td>
|
||||
<td>{{ item | user_link}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
<a class="waves-effect waves-light btn" href="/m/inventory/add">Add item</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
23
inventory-app/templates/inventory_add_slug.html
Normal file
23
inventory-app/templates/inventory_add_slug.html
Normal file
@ -0,0 +1,23 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
|
||||
<h3>New slug: {{ slug }}</h3>
|
||||
<p>
|
||||
You are adding a new slug to the collection.
|
||||
It can be assigned to an existing item or a new item can be created.
|
||||
</p>
|
||||
<h3>Actions</h3>
|
||||
<div class="row">
|
||||
<a href="/m/inventory/add-by-slug/{{ slug }}" class="waves-effect waves-light btn">Add new item</a>
|
||||
</div>
|
||||
<div class="row">
|
||||
<a href="/m/inventory/assign-slug/{{ slug }}" class="waves-effect waves-light btn">Assign to existing item</a>
|
||||
</div>
|
||||
<div class="row">
|
||||
<a href="/m/inventory/clone-with-slug/{{ slug }}" class="waves-effect waves-light btn">Clone existing item</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
192
inventory-app/templates/inventory_edit.html
Normal file
192
inventory-app/templates/inventory_edit.html
Normal file
@ -0,0 +1,192 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<form method="POST" autocomplete="off">
|
||||
{{ form.csrf_token }}
|
||||
<p>Inventory item.</p>
|
||||
|
||||
<div id="errors" style="background-color: red;">
|
||||
{% for field, errors in form.errors.items() %}
|
||||
<div>
|
||||
{{ form[field].label.text }} :
|
||||
{% if errors.items %}
|
||||
{% for error, msg in errors.items() %}
|
||||
{{ form[field][error].label.text }} :
|
||||
{{ ", ".join(msg) }}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{{ ", ".join(errors) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if custom_errors %}
|
||||
{% for field, error in custom_errors.items() %}
|
||||
<div>{{ field }} : {{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td>Type</td>
|
||||
<td>{{ form["type"] }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Vendor</td>
|
||||
<td>{{ form.hardware.vendor }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Product</td>
|
||||
<td>{{ form.hardware.product }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Serial number</td>
|
||||
<td>{{ form.hardware.serial }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>{{ form.name }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Comment</td>
|
||||
<td>{{ form.comment }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Owner</td>
|
||||
<td>{{ form.inventory.owner.foreign_id }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Current user</td>
|
||||
<td>{{ form.inventory.user.foreign_id }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Issue Tracker</td>
|
||||
<td>{{ form.issue_tracker }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>URL slug</td>
|
||||
<td>{{ form.shortener.slug }}</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<label>
|
||||
{{ form.inventory.usable }}
|
||||
<span>Usable, if available other members can make use of this inventory item</span>
|
||||
</label>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>
|
||||
{{ form.inventory.public }}
|
||||
<span>Public, show this inventory item for unauthenticated users</span>
|
||||
</label>
|
||||
</p>
|
||||
|
||||
<h5>Tags</h5>
|
||||
<div class="placeholder-dark chips">
|
||||
<input style="width: auto !important;">
|
||||
</div>
|
||||
<h5>Description</h5>
|
||||
<p class="auto-height placeholder-dark">
|
||||
{{ form.description(rows='15',cols='100') }}
|
||||
</p>
|
||||
|
||||
<button class="btn waves-effect waves-light" type="submit" name="action">Submit
|
||||
<i class="material-icons right">send</i>
|
||||
</button>
|
||||
{% for tag in item_tags %}
|
||||
<input class="tag" type="hidden" name="tags[]" value="{{ tag }}">
|
||||
{% endfor %}
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
$(function() {
|
||||
$("input#hardware-vendor, input#hardware-product, input#hardware-serial").keyup(function(){
|
||||
updateName();
|
||||
});
|
||||
|
||||
function updateName() {
|
||||
var names = [$("input#hardware-vendor").val(), $("input#hardware-product").val(), $("input#hardware-serial").val()];
|
||||
$("input#name").val(names.filter(x => x).join(" "));
|
||||
}
|
||||
{% if not has_errors %}
|
||||
{% if slug %}
|
||||
location.href="#shortener-slug";
|
||||
$("input#shortener-slug").focus();
|
||||
{% endif %}
|
||||
{% if clone_item_id %}
|
||||
updateName();
|
||||
{% endif %}
|
||||
{% else %}
|
||||
location.href="#errors";
|
||||
{% endif %}
|
||||
setupTags();
|
||||
|
||||
$(document).click(function() {
|
||||
$('li[id^="select-options"]').on('touchend', function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
|
||||
function setupTags() {
|
||||
$('.chips').chips({
|
||||
placeholder: 'Enter a tag',
|
||||
secondaryPlaceholder: '+Tag',
|
||||
data: [
|
||||
{% for tag in item_tags %}
|
||||
{ tag: '{{ tag }}', },
|
||||
{% endfor %}
|
||||
],
|
||||
autocompleteOptions: {
|
||||
data: {
|
||||
{% for tag in all_tags %}
|
||||
'{{ tag }}': null,
|
||||
{% endfor %}
|
||||
},
|
||||
limit: Infinity,
|
||||
minLength: 0
|
||||
},
|
||||
onChipAdd: updateTags,
|
||||
onChipDelete: updateTags,
|
||||
});
|
||||
}
|
||||
|
||||
function updateTags(e) {
|
||||
var form = $('form');
|
||||
var instance = M.Chips.getInstance($('.chips'));
|
||||
var tags = instance.chipsData;
|
||||
form.find('input.tag').remove();
|
||||
for (var i = 0; i < tags.length; i++) {
|
||||
var tag = tags[i]["tag"];
|
||||
$('<input>', {
|
||||
'type': 'hidden',
|
||||
'name': 'tags[]',
|
||||
'class': 'tag',
|
||||
'value': tag
|
||||
}).appendTo(form);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
91
inventory-app/templates/inventory_filter.html
Normal file
91
inventory-app/templates/inventory_filter.html
Normal file
@ -0,0 +1,91 @@
|
||||
<form id="filter-form" method="get" action="?">
|
||||
<div class="row">
|
||||
{% for key, title, values, selected in selectors %}
|
||||
{% if selected | is_list %}
|
||||
<div class="input-field col s12 m2">
|
||||
<select name="{{ key }}" multiple>
|
||||
{% for value in values %}
|
||||
<option value="{{ value }}" {% if value in selected %}selected{% endif %}>
|
||||
{{ value }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label>{{ title }}</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="input-field col s12 m2">
|
||||
<select name="{{ key }}">
|
||||
<option value="" disabled {% if not selected %}selected{% endif %}>All</option>
|
||||
{% for value in values %}
|
||||
{% if value is mapping %}
|
||||
<option value="{{ value.foreign_id }}" {% if value.foreign_id == selected %}selected{% endif %}>
|
||||
{{ value.display_name }}
|
||||
</option>
|
||||
{% else %}
|
||||
<option value="{{ value }}" {% if value == selected %}selected{% endif %}>
|
||||
{{ value }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label>{{ title }}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="input-field col s12 m3">
|
||||
<select name="sort_field">
|
||||
{% for key, value in sort_fields.items() %}
|
||||
<option value="{{ key }}" {% if key == sort_field %}selected{% endif %}>{{ value }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label>Sort field</label>
|
||||
</div>
|
||||
<div class="input-field col s12 m3">
|
||||
<select name="sort_direction">
|
||||
<option value="desc" {% if sort_direction == "desc" %}selected{% endif %}>Descending</option>
|
||||
<option value="asc" {% if sort_direction == "asc" %}selected{% endif %}>Ascending</option>
|
||||
</select>
|
||||
<label>Sort direction</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
{% if not pick %}
|
||||
<div class="input-field col s12 m2">
|
||||
<label>
|
||||
<input type="checkbox" value="true"
|
||||
name="grid" {% if grid %} checked="checked" {% endif %}/>
|
||||
<span>Grid view</span>
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not public_view %}
|
||||
<div class="input-field col s12 m2">
|
||||
<label>
|
||||
<input type="checkbox" value="true"
|
||||
name="owner_disabled" {% if owner_disabled %} checked="checked" {% endif %}/>
|
||||
<span>Owner disabled</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="input-field col s12 m2">
|
||||
<label>
|
||||
<input type="checkbox" value="true"
|
||||
name="user_disabled" {% if user_disabled %} checked="checked" {% endif %}/>
|
||||
<span>User disabled</span>
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="input-field col s12 m2" name="item_type">
|
||||
<button type="submit">Apply</button>
|
||||
<button type="reset">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
$(function() {
|
||||
$("#filter-form button[type='reset']").click(function() {
|
||||
$("#filter-form option").removeAttr("selected");
|
||||
$("#filter-form option[value='']").attr("selected", true);
|
||||
$("#filter-form input[type='checkbox']").attr("checked", false);
|
||||
});
|
||||
});
|
||||
</script>
|
31
inventory-app/templates/inventory_grid.html
Normal file
31
inventory-app/templates/inventory_grid.html
Normal file
@ -0,0 +1,31 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
{% include "inventory_filter.html" %}
|
||||
<div class="row">
|
||||
{% for item in items %}
|
||||
<div class="col s4">
|
||||
<div class="card medium">
|
||||
<div class="card-image">
|
||||
<img src="{% if item.has_photo %}/m/photo/{{ item._id }}/576{% else %}/static/No_image_available.svg{% endif %}" alt="no photo" loading="lazy">
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<span class="line-clamp card-title activator grey-text text-darken-4">{{ item.name }}</span>
|
||||
{% if public_view %}
|
||||
{% if item.inventory.user %}<p>In use</p>{% endif %}
|
||||
{% else %}
|
||||
<p>Owner: {% if item.inventory.owner %}{{item.inventory.owner.display_name}}{% endif %}</p>
|
||||
<p>Current user: {% if item.inventory.user %}{{item.inventory.user.display_name}}{% endif %}</p>
|
||||
{% endif %}
|
||||
<p><a href="/m/inventory/{{ item._id }}/view">more</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p>
|
||||
<a class="waves-effect waves-light btn" href="/m/inventory/add">Add item</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
31
inventory-app/templates/inventory_pick.html
Normal file
31
inventory-app/templates/inventory_pick.html
Normal file
@ -0,0 +1,31 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h4>{{ action_help }}: {{ slug }}</h4>
|
||||
{% include "inventory_filter.html" %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Owner</th>
|
||||
<th>User</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>{{ item | format_name }} {{ item.comment }}</td>
|
||||
<td>{% if item.inventory.owner %}<a href="/m/user/{{ item.inventory.owner.foreign_id }}">{{ item.inventory.owner.display_name }}</a>{% endif %}</td>
|
||||
<td>{% if item.inventory.user %}<a href="/m/user/{{ item.inventory.user.foreign_id }}">{{ item.inventory.user.display_name }}</a>{% endif %}</td>
|
||||
<td>
|
||||
<form action="/m/inventory/{{item._id}}/{{action_path}}/{{slug}}" method="get">
|
||||
<button class="waves-effect waves-light btn" type="submit">{{action_label}}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
32
inventory-app/templates/inventory_public.html
Normal file
32
inventory-app/templates/inventory_public.html
Normal file
@ -0,0 +1,32 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h6>Please log in to see more details</h6>
|
||||
{% include "inventory_filter.html" %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Slug</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>In use</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if item.shortener %}
|
||||
<a href="http://k6.ee/{{ item.shortener.slug }}">{{ item.shortener.slug }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><a href="/m/inventory/{{ item._id }}/view">{{ item | format_name }} {{ item.comment }}</a></td>
|
||||
<td>{{ item.type }}</td>
|
||||
<td>{% if item.inventory.user %}<i class="material-icons">check_circle</i>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
169
inventory-app/templates/inventory_view.html
Normal file
169
inventory-app/templates/inventory_view.html
Normal file
@ -0,0 +1,169 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<p>Inventory item.</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td>Type</td>
|
||||
<td>{{ item["type"] }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Vendor</td>
|
||||
<td>{{ item.get("hardware").vendor }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Product</td>
|
||||
<td>{{ item.get("hardware").product }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Serial number</td>
|
||||
<td>{{ item.get("hardware").serial }}</td>
|
||||
</tr>
|
||||
|
||||
{% if item.mac %}
|
||||
<tr>
|
||||
<td>MAC address</td>
|
||||
<td>
|
||||
<span class="tooltipped" data-tooltip="Show in machines view">
|
||||
<a href="/m/machine?mac={{ item.mac | quote_plus }}">
|
||||
{{ item.mac }}
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>{{ item.name }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Comment</td>
|
||||
<td>{{ item.comment }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Owner</td>
|
||||
<td>{{ item.inventory.get("owner").display_name }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Current user</td>
|
||||
<td>{{ item.inventory.get("user").display_name }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>URL slug</td>
|
||||
<td>
|
||||
{% if item.get("shortener").slug %}
|
||||
<a href="http://k6.ee/{{ item.get("shortener").slug }}">
|
||||
k6.ee/{{ item.get("shortener").slug }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Issue tracker</td>
|
||||
<td><a href="{{ item.issue_tracker }}">Issue tracker</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="tooltip" data-tooltip="Unauthenticated user can see this in public list">Public</td>
|
||||
<td>{% if item.inventory.public %}<i class="material-icons">check_circle</i>{% endif %}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="tooltip" data-tooltip="Other members can make use of this inventory item">Usable</td>
|
||||
<td>{% if item.inventory.usable %}<i class="material-icons">check_circle</i>{% endif %}</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Tags</h3>
|
||||
{% for tag in item.tags %}
|
||||
<div class="chip"> {{ tag }} </div>
|
||||
{% endfor %}
|
||||
<h3>Description</h3>
|
||||
<p class="auto-height">
|
||||
{{ item.description | markdown }}
|
||||
</p>
|
||||
|
||||
<h3>Photo</h3>
|
||||
{% if item.has_photo %}
|
||||
<img src="{{ photo_url }}" alt="" style="max-width: 800px; width: 100%;">
|
||||
{% endif %}
|
||||
<form id="photo-form" action="/inventory/{{ item._id }}/upload-photo" method="post" enctype="multipart/form-data">
|
||||
<div class="row placeholder-dark">
|
||||
<div class="file-field input-field col s6">
|
||||
<div class="btn">
|
||||
<span>Select</span>
|
||||
<input type="file" name="file" accept="image/jpeg" />
|
||||
</div>
|
||||
|
||||
<div class="file-path-wrapper">
|
||||
<input class="file-path validate" type="text" placeholder="Upload file" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-field input-field col s6">
|
||||
<button class="btn waves-effect waves-light" type="submit" name="action">Upload
|
||||
<i class="material-icons right">add_a_photo</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<h3>Actions</h3>
|
||||
{% if not item.inventory.user and item.inventory.usable %}
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<form action="/m/inventory/{{ item._id }}/use" method="post" style="display: inline;">
|
||||
<button class="waves-effect waves-light btn" type="submit">Use</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<a href="/m/inventory/{{ item._id }}/edit" class="waves-effect waves-light btn">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<a href="/m/inventory/{{ item._id }}/clone" class="waves-effect waves-light btn">Clone</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
$(function() {
|
||||
var photoInput = $("#photo-form input[type=file]");
|
||||
photoInput.change(function () {
|
||||
var fileName = $.trim($(this).val());
|
||||
var button = $("#photo-form button[type=submit]");
|
||||
if (fileName === "") {
|
||||
button.prop("disabled", true);
|
||||
} else {
|
||||
button.prop("disabled", false);
|
||||
}
|
||||
});
|
||||
photoInput.change();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
65
inventory-app/templates/inventory_view_public.html
Normal file
65
inventory-app/templates/inventory_view_public.html
Normal file
@ -0,0 +1,65 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<p>Inventory item.</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td>Type</td>
|
||||
<td>{{ item["type"] }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Vendor</td>
|
||||
<td>{{ item.get("hardware").vendor }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Product</td>
|
||||
<td>{{ item.get("hardware").product }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>{{ item.name }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>URL slug</td>
|
||||
<td>
|
||||
{% if item.get("shortener").slug %}
|
||||
<a href="http://k6.ee/{{ item.get("shortener").slug }}">
|
||||
k6.ee/{{ item.get("shortener").slug }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="tooltip" data-tooltip="Unauthenticated user can see this in public list">In use</td>
|
||||
<td>{% if item.inventory.user %}<i class="material-icons">check_circle</i>{% endif %}</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Description</h3>
|
||||
<p class="auto-height">
|
||||
{{ item.description | markdown }}
|
||||
</p>
|
||||
|
||||
<h3>Photo</h3>
|
||||
{% if item.has_photo %}
|
||||
<img src="{{ photo_url }}" alt="" style="max-width: 800px; width: 100%;">
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
30
inventory-app/templates/login.html
Normal file
30
inventory-app/templates/login.html
Normal file
@ -0,0 +1,30 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
|
||||
<div class="row">
|
||||
<div class="col s6">
|
||||
{% if not devenv %}
|
||||
<p>If you have active AD account click <a href="/login/authelia">here</a> to login</p>
|
||||
{% else %}
|
||||
<p>Click <a href="/dev_login">here</a> to login as dev user</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col s6">
|
||||
<p>Request a login link to your email address</p>
|
||||
<form action="/login/address" method="post">
|
||||
{{ form.csrf_token }}
|
||||
<p>{{ form.email.label }}</p>
|
||||
<p>{{ form.email }}</p>
|
||||
<p>{{ form.recaptcha }}</p>
|
||||
<button class="waves-effect waves-light btn" type="submit">Request login link</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
12
inventory-app/templates/login_error.html
Normal file
12
inventory-app/templates/login_error.html
Normal file
@ -0,0 +1,12 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<p>
|
||||
Your membership is not active or is suspended, please reach out to <a href="mailto:info@k-space.ee">info@k-space.ee</a> for more info
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
10
inventory-app/templates/login_link_request.html
Normal file
10
inventory-app/templates/login_link_request.html
Normal file
@ -0,0 +1,10 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
|
||||
<p>If the address is known a login link should have been sent.</p>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
15
inventory-app/templates/menu.html
Normal file
15
inventory-app/templates/menu.html
Normal file
@ -0,0 +1,15 @@
|
||||
<li><a href="{{ members_host }}/">Public</a></li>
|
||||
<li><a href="{{ members_host }}/m/profile">Me</a></li>
|
||||
<li><a href="{{ members_host }}/m/phonebook">Phonebook</a></li>
|
||||
{% if user and user.access.board %}
|
||||
<li><a href="{{ members_host }}/m/packages">Packages</a></li>
|
||||
<li><a href="{{ members_host }}/m/transactions">Transactions</a></li>
|
||||
<li><a href="{{ members_host }}/m/debtors">Debtors</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{{ members_host }}/m/cashflow">Cashflow</a></li>
|
||||
<li><a href="{{ members_host }}/m/lockers">Lockers</a></li>
|
||||
<li><a href="{{ members_host }}/m/desks">Desks</a></li>
|
||||
<li><a href="{{ members_host }}/m/doorboy">Doorboy™</a></li>
|
||||
<li><a href="{{ members_host }}/m/machine?vlan_number=1">Machines</a></li>
|
||||
<li><a href="/m/inventory?type=machine&type=locker&type=desk">Inventory</a></li>
|
||||
<li><a href="{{ members_host }}/m/cameras">Cams</a></li>
|
11
inventory-app/templates/notice.html
Normal file
11
inventory-app/templates/notice.html
Normal file
@ -0,0 +1,11 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<p>Please request one of current members to send you the login link</p>
|
||||
|
||||
<p>We stopped using AD user login on this site to faciliate login for users who don't (want to) have AD account</p>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
42
inventory-app/templates/privacy.html
Normal file
42
inventory-app/templates/privacy.html
Normal file
@ -0,0 +1,42 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<p>Contact information for our membership, be gentle!</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="tooltipped" data-tooltip="This mail alias is shown in Gogs, Nextcloud etc services in order to not expose users personal e-mail address. Mails are forwarded to personal address. This is UPN or 'userPrincipalName' in AD, if alias is shown disabled check that it uses correct UPN suffix @k-space.ee and personal mail attribute is set to enable forwarding">Mail alias</th>
|
||||
<th class="tooltipped" data-tooltip="This is AD 'Pager' field">Personal email</th>
|
||||
<th class="tooltipped" data-tooltip="This is AD 'Telephone number' field. For reaching member out of band">Phone</th>
|
||||
<th class="tooltipped" data-tooltip="This is AD 'Homepage' field">Homepage</th>
|
||||
|
||||
<th class="tooltipped" data-tooltip="This person receives copies of e-mails sent to info@k-space.ee mail alias. Inferred from membership of 'Info' AD group.">Info</th>
|
||||
<th class="tooltipped" data-tooltip="This person can add users in AD domain and reset user passwords. Inferred from membership of 'Onboarding' group.">Onboarding</th>
|
||||
<!-- <th class="tooltipped" data-tooltip="This person can reissue VPN access at ca5.certidude.rocks site. Inferred from membership of 'VPN Admins' AD group.">VPN admin</th>
|
||||
<th class="tooltipped" data-tooltip="This person can administer AD domain and help out in corner cases. Inferred from membership of 'Domain Admins' AD group.">AD admin</th> -->
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in members %}
|
||||
{% if p.type != "company" %}
|
||||
<tr style="{% if not p.enabled %}opacity:25%;{% endif %}">
|
||||
<td><a href="/m/user/{{ p.full_name }}">{{ p.full_name }}</a></td>
|
||||
<td><a href="mailto:{{ p.mail_alias }}">{{ p.mail_alias }}</a></td>
|
||||
<td><a href="mailto:{{ p.mail }}">{{ p.mail }}</a></td>
|
||||
<td>{% if p.phone %}<a href="tel:{{p.phone}}">{{ p.phone }}</a>{% else %}-{% endif %}</td>
|
||||
<td>{% if p.homepage %}<a href="http://{{ p.homepage }}">{{ p.homepage }}</a>{% else %}-{% endif %}</td>
|
||||
<td>{% if p.access and p.access.info %}<i class="material-icons">check_circle</i>{% else %} {% endif %}</td>
|
||||
<td>{% if p.access and p.access.onboarding %}<i class="material-icons">check_circle</i>{% else %} {% endif %}</td>
|
||||
<!-- <td>{% if p.access and p.access.vpn_admins %}<i class="material-icons">check_circle</i>{% else %} {% endif %}</td>
|
||||
<td>{% if p.access and p.access.domain_admins %}<i class="material-icons">check_circle</i>{% else %} {% endif %}</td> -->
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
15
requirements.txt
Normal file
15
requirements.txt
Normal file
@ -0,0 +1,15 @@
|
||||
bleach
|
||||
boto3
|
||||
coverage
|
||||
email_validator
|
||||
gunicorn
|
||||
Flask
|
||||
jinja2
|
||||
jpegtran-cffi
|
||||
markdown
|
||||
requests
|
||||
safe
|
||||
sepa
|
||||
Flask-WTF
|
||||
prometheus-flask-exporter
|
||||
pymongo
|
Loading…
Reference in New Issue
Block a user