commit 2b8820d4d79602346fdb9f58f6ca1d5d0dddcc9d Author: Madis Mägi Date: Fri Jun 16 13:52:49 2023 +0300 Move inventory to new repo diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..fdb2fb2 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,5 @@ +Members site contributors +========================= + +* Madis Mägi +* Lauri Võsandi diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..097e93c --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/inventory-app/common.py b/inventory-app/common.py new file mode 100644 index 0000000..8c95387 --- /dev/null +++ b/inventory-app/common.py @@ -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 diff --git a/inventory-app/const.py b/inventory-app/const.py new file mode 100644 index 0000000..3e5f75d --- /dev/null +++ b/inventory-app/const.py @@ -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"] diff --git a/inventory-app/decorators.py b/inventory-app/decorators.py new file mode 100644 index 0000000..5de84d5 --- /dev/null +++ b/inventory-app/decorators.py @@ -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 diff --git a/inventory-app/inventory.py b/inventory-app/inventory.py new file mode 100644 index 0000000..b377873 --- /dev/null +++ b/inventory-app/inventory.py @@ -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//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//edit", methods=['GET']) +@page_inventory.route("/m/inventory//edit-by-slug/", methods=['GET']) +@page_inventory.route("/m/inventory/add", methods=['GET']) +@page_inventory.route("/m/inventory/add-by-slug/", methods=['GET']) +@page_inventory.route("/m/inventory//clone", methods=['GET']) +@page_inventory.route("/m/inventory//clone-by-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//edit", methods=['POST']) +@page_inventory.route("/m/inventory//edit-by-slug/", methods=['POST']) +@page_inventory.route("/m/inventory/add", methods=['POST']) +@page_inventory.route("/m/inventory/add-by-slug/", methods=['POST']) +@page_inventory.route("/m/inventory//clone", methods=['POST']) +@page_inventory.route("/m/inventory//clone-by-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/", 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//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//") +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/") +@page_inventory.route("/m/inventory/clone-with-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//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//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//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"]) diff --git a/inventory-app/main.py b/inventory-app/main.py new file mode 100755 index 0000000..8ab098c --- /dev/null +++ b/inventory-app/main.py @@ -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("" % (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("%s" % (owner["foreign_id"], owner["display_name"])) + else: + return Markup("
" % (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("%s" % (user["foreign_id"], user["display_name"])) + elif item.get("inventory", {}).get("usable"): + return Markup("
" % (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/") +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//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='::') diff --git a/inventory-app/templates/base.html b/inventory-app/templates/base.html new file mode 100644 index 0000000..59c43e1 --- /dev/null +++ b/inventory-app/templates/base.html @@ -0,0 +1,106 @@ + + + + + + + + + + + + +
    + {% include "menu.html" %} +
+ + {% block content %} + {% endblock %} + + + + + + diff --git a/inventory-app/templates/inventory.html b/inventory-app/templates/inventory.html new file mode 100644 index 0000000..98a64c9 --- /dev/null +++ b/inventory-app/templates/inventory.html @@ -0,0 +1,38 @@ +{% extends 'base.html' %} + +{% block content %} +
+ {% include "inventory_filter.html" %} + + + + + + + + + + + + + {% for item in items %} + + + + + + + + + {% endfor %} + +
SlugPublicNameTypeOwnerUser
+ {% if item.shortener %} + {{ item.shortener.slug }} + {% endif %} + {% if item.inventory.public %}check_circle{% else %} {% endif %}{{ item | format_name }} {{ item.comment }}{{ item.type }}{{ item | owner_link}}{{ item | user_link}}
+

+ Add item +

+
+{% endblock %} diff --git a/inventory-app/templates/inventory_add_slug.html b/inventory-app/templates/inventory_add_slug.html new file mode 100644 index 0000000..b1d2f54 --- /dev/null +++ b/inventory-app/templates/inventory_add_slug.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} +{% block content %} +
+ +

New slug: {{ slug }}

+

+You are adding a new slug to the collection. +It can be assigned to an existing item or a new item can be created. +

+

Actions

+ + + + +
+{% endblock %} + diff --git a/inventory-app/templates/inventory_edit.html b/inventory-app/templates/inventory_edit.html new file mode 100644 index 0000000..9ab4fad --- /dev/null +++ b/inventory-app/templates/inventory_edit.html @@ -0,0 +1,192 @@ +{% extends 'base.html' %} +{% block content %} +
+
+{{ form.csrf_token }} +

Inventory item.

+ +
+{% for field, errors in form.errors.items() %} +
+ {{ 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 %} +
+{% endfor %} +{% if custom_errors %} + {% for field, error in custom_errors.items() %} +
{{ field }} : {{ error }}
+ {% endfor %} +{% endif %} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
Type{{ form["type"] }}
Vendor{{ form.hardware.vendor }}
Product{{ form.hardware.product }}
Serial number{{ form.hardware.serial }}
Name{{ form.name }}
Comment{{ form.comment }}
Owner{{ form.inventory.owner.foreign_id }}
Current user{{ form.inventory.user.foreign_id }}
Issue Tracker{{ form.issue_tracker }}
URL slug{{ form.shortener.slug }}
+ +

+ +

+ +

+ +

+ +
Tags
+
+ +
+
Description
+

+ {{ form.description(rows='15',cols='100') }} +

+ + +{% for tag in item_tags %} + +{% endfor %} +
+ +
+ +{% endblock %} + diff --git a/inventory-app/templates/inventory_filter.html b/inventory-app/templates/inventory_filter.html new file mode 100644 index 0000000..917cec2 --- /dev/null +++ b/inventory-app/templates/inventory_filter.html @@ -0,0 +1,91 @@ +
+
+ {% for key, title, values, selected in selectors %} + {% if selected | is_list %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} + {% endfor %} +
+ + +
+
+ + +
+
+
+ {% if not pick %} +
+ +
+ {% endif %} + {% if not public_view %} +
+ +
+
+ +
+ {% endif %} +
+ + +
+
+
+ diff --git a/inventory-app/templates/inventory_grid.html b/inventory-app/templates/inventory_grid.html new file mode 100644 index 0000000..6339241 --- /dev/null +++ b/inventory-app/templates/inventory_grid.html @@ -0,0 +1,31 @@ +{% extends 'base.html' %} + +{% block content %} +
+ {% include "inventory_filter.html" %} +
+ {% for item in items %} +
+
+
+ no photo +
+
+ {{ item.name }} + {% if public_view %} + {% if item.inventory.user %}

In use

{% endif %} + {% else %} +

Owner: {% if item.inventory.owner %}{{item.inventory.owner.display_name}}{% endif %}

+

Current user: {% if item.inventory.user %}{{item.inventory.user.display_name}}{% endif %}

+ {% endif %} +

more

+
+
+
+ {% endfor %} +
+

+ Add item +

+
+{% endblock %} diff --git a/inventory-app/templates/inventory_pick.html b/inventory-app/templates/inventory_pick.html new file mode 100644 index 0000000..0f30150 --- /dev/null +++ b/inventory-app/templates/inventory_pick.html @@ -0,0 +1,31 @@ +{% extends 'base.html' %} + +{% block content %} +
+

{{ action_help }}: {{ slug }}

+ {% include "inventory_filter.html" %} + + + + + + + + + + {% for item in items %} + + + + + + + {% endfor %} + +
NameOwnerUser
{{ item | format_name }} {{ item.comment }}{% if item.inventory.owner %}{{ item.inventory.owner.display_name }}{% endif %}{% if item.inventory.user %}{{ item.inventory.user.display_name }}{% endif %} +
+ +
+
+
+{% endblock %} diff --git a/inventory-app/templates/inventory_public.html b/inventory-app/templates/inventory_public.html new file mode 100644 index 0000000..aac080a --- /dev/null +++ b/inventory-app/templates/inventory_public.html @@ -0,0 +1,32 @@ +{% extends 'base.html' %} + +{% block content %} +
+
Please log in to see more details
+ {% include "inventory_filter.html" %} + + + + + + + + + + + {% for item in items %} + + + + + + + {% endfor %} + +
SlugNameTypeIn use
+ {% if item.shortener %} + {{ item.shortener.slug }} + {% endif %} + {{ item | format_name }} {{ item.comment }}{{ item.type }}{% if item.inventory.user %}check_circle{% endif %}
+
+{% endblock %} diff --git a/inventory-app/templates/inventory_view.html b/inventory-app/templates/inventory_view.html new file mode 100644 index 0000000..f146fdb --- /dev/null +++ b/inventory-app/templates/inventory_view.html @@ -0,0 +1,169 @@ +{% extends 'base.html' %} +{% block content %} +
+

Inventory item.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if item.mac %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
Type{{ item["type"] }}
Vendor{{ item.get("hardware").vendor }}
Product{{ item.get("hardware").product }}
Serial number{{ item.get("hardware").serial }}
MAC address + + + {{ item.mac }} + + +
Name{{ item.name }}
Comment{{ item.comment }}
Owner{{ item.inventory.get("owner").display_name }}
Current user{{ item.inventory.get("user").display_name }}
URL slug + {% if item.get("shortener").slug %} + + k6.ee/{{ item.get("shortener").slug }} + + {% endif %} +
Issue trackerIssue tracker
Public{% if item.inventory.public %}check_circle{% endif %}
Usable{% if item.inventory.usable %}check_circle{% endif %}
+ +

Tags

+{% for tag in item.tags %} +
{{ tag }}
+{% endfor %} +

Description

+

+ {{ item.description | markdown }} +

+ +

Photo

+{% if item.has_photo %} + +{% endif %} +
+
+
+
+ Select + +
+ +
+ +
+
+
+ +
+
+
+

Actions

+{% if not item.inventory.user and item.inventory.usable %} +
+
+
+ +
+
+
+{% endif %} + +
+
+ Edit +
+
+ +
+
+ Clone +
+
+ +
+ +{% endblock %} + diff --git a/inventory-app/templates/inventory_view_public.html b/inventory-app/templates/inventory_view_public.html new file mode 100644 index 0000000..2957d91 --- /dev/null +++ b/inventory-app/templates/inventory_view_public.html @@ -0,0 +1,65 @@ +{% extends 'base.html' %} +{% block content %} +
+

Inventory item.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
Type{{ item["type"] }}
Vendor{{ item.get("hardware").vendor }}
Product{{ item.get("hardware").product }}
Name{{ item.name }}
URL slug + {% if item.get("shortener").slug %} + + k6.ee/{{ item.get("shortener").slug }} + + {% endif %} +
In use{% if item.inventory.user %}check_circle{% endif %}
+ +

Description

+

+ {{ item.description | markdown }} +

+ +

Photo

+{% if item.has_photo %} + +{% endif %} + +{% endblock %} + diff --git a/inventory-app/templates/login.html b/inventory-app/templates/login.html new file mode 100644 index 0000000..5d85935 --- /dev/null +++ b/inventory-app/templates/login.html @@ -0,0 +1,30 @@ +{% extends 'base.html' %} + +{% block content %} +
+ +
+
+ {% if not devenv %} +

If you have active AD account click here to login

+ {% else %} +

Click here to login as dev user

+ {% endif %} +
+
+

Request a login link to your email address

+
+ {{ form.csrf_token }} +

{{ form.email.label }}

+

{{ form.email }}

+

{{ form.recaptcha }}

+ +
+
+
+ + + +
+{% endblock %} + diff --git a/inventory-app/templates/login_error.html b/inventory-app/templates/login_error.html new file mode 100644 index 0000000..7c8e1e4 --- /dev/null +++ b/inventory-app/templates/login_error.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} + +{% block content %} +
+

+Your membership is not active or is suspended, please reach out to info@k-space.ee for more info +

+ +
+ +{% endblock %} + diff --git a/inventory-app/templates/login_link_request.html b/inventory-app/templates/login_link_request.html new file mode 100644 index 0000000..9bd28ce --- /dev/null +++ b/inventory-app/templates/login_link_request.html @@ -0,0 +1,10 @@ +{% extends 'base.html' %} + +{% block content %} +
+ +

If the address is known a login link should have been sent.

+ +
+{% endblock %} + diff --git a/inventory-app/templates/menu.html b/inventory-app/templates/menu.html new file mode 100644 index 0000000..5918a7f --- /dev/null +++ b/inventory-app/templates/menu.html @@ -0,0 +1,15 @@ +
  • Public
  • +
  • Me
  • +
  • Phonebook
  • +{% if user and user.access.board %} +
  • Packages
  • +
  • Transactions
  • +
  • Debtors
  • +{% endif %} +
  • Cashflow
  • +
  • Lockers
  • +
  • Desks
  • +
  • Doorboy™
  • +
  • Machines
  • +
  • Inventory
  • +
  • Cams
  • diff --git a/inventory-app/templates/notice.html b/inventory-app/templates/notice.html new file mode 100644 index 0000000..a064911 --- /dev/null +++ b/inventory-app/templates/notice.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} + +{% block content %} +
    +

    Please request one of current members to send you the login link

    + +

    We stopped using AD user login on this site to faciliate login for users who don't (want to) have AD account

    + +
    +{% endblock %} + diff --git a/inventory-app/templates/privacy.html b/inventory-app/templates/privacy.html new file mode 100644 index 0000000..01a953e --- /dev/null +++ b/inventory-app/templates/privacy.html @@ -0,0 +1,42 @@ +{% extends 'base.html' %} +{% block content %} +
    +

    Contact information for our membership, be gentle!

    + + + + + + + + + + + + + + + + + + {% for p in members %} + {% if p.type != "company" %} + + + + + + + + + + + {% endif %} + {% endfor %} + +
    NameMail aliasPersonal emailPhoneHomepageInfoOnboarding
    {{ p.full_name }}{{ p.mail_alias }}{{ p.mail }}{% if p.phone %}{{ p.phone }}{% else %}-{% endif %}{% if p.homepage %}{{ p.homepage }}{% else %}-{% endif %}{% if p.access and p.access.info %}check_circle{% else %} {% endif %}{% if p.access and p.access.onboarding %}check_circle{% else %} {% endif %}
    +
    +{% endblock %} + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1982127 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +bleach +boto3 +coverage +email_validator +gunicorn +Flask +jinja2 +jpegtran-cffi +markdown +requests +safe +sepa +Flask-WTF +prometheus-flask-exporter +pymongo