Move inventory to new repo

This commit is contained in:
Madis Mägi 2023-06-16 13:52:49 +03:00
commit 2b8820d4d7
25 changed files with 1903 additions and 0 deletions

5
CONTRIBUTORS.md Normal file
View File

@ -0,0 +1,5 @@
Members site contributors
=========================
* Madis Mägi
* Lauri Võsandi

15
Dockerfile Normal file
View 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

0
README.md Normal file
View File

108
inventory-app/common.py Normal file
View 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
View 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
View 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
View 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
View 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='::')

View 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>

View 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 %}&nbsp;{% 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 %}

View 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 %}

View 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 %}

View 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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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>

View 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 %}

View 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&nbsp;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&nbsp;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 %}&nbsp;{% endif %}</td>
<td>{% if p.access and p.access.onboarding %}<i class="material-icons">check_circle</i>{% else %}&nbsp;{% endif %}</td>
<!-- <td>{% if p.access and p.access.vpn_admins %}<i class="material-icons">check_circle</i>{% else %}&nbsp;{% endif %}</td>
<td>{% if p.access and p.access.domain_admins %}<i class="material-icons">check_circle</i>{% else %}&nbsp;{% endif %}</td> -->
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

15
requirements.txt Normal file
View 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