Compare commits
1 Commits
doorboy-di
...
treeview
Author | SHA1 | Date | |
---|---|---|---|
0ab5fc4570 |
15
Dockerfile
15
Dockerfile
@@ -4,17 +4,12 @@ RUN apt-get update \
|
||||
curl ca-certificates iputils-ping \
|
||||
python3-ldap python3-pip python3-ldap \
|
||||
libjpeg-dev libturbojpeg0-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt ./
|
||||
&& 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
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV ENVIRONMENT_TYPE=PROD
|
||||
|
||||
COPY inventory-app /app
|
||||
WORKDIR /app
|
||||
COPY inventory-app .
|
||||
|
||||
ENTRYPOINT ["python3", "/app/main.py"]
|
||||
ENTRYPOINT /app/main.py
|
||||
|
@@ -10,5 +10,3 @@
|
||||
|k-space:inventory:audit|Update last time item information confirmed to be accurate|
|
||||
|k-space:inventory:edit|Edit all non-key items. Browse items with Protected visibility.|
|
||||
|k-space:inventory:keys|Edit keys|
|
||||
|
||||
For door access, assumes `k-space:floor` and `k-space:workshop`, same in doorboy-proxy.
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# This is unmaintained. See git.k-space.ee/k-space/kube//hackerspace/inventory.yaml
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
@@ -24,6 +23,14 @@ spec:
|
||||
env:
|
||||
- name: OIDC_USERS_NAMESPACE
|
||||
value: "default"
|
||||
- name: SLACK_DOORLOG_CALLBACK
|
||||
value: "changeme"
|
||||
- name: SLACK_VERIFICATION_TOKEN
|
||||
value: "changeme"
|
||||
- name: INVENTORY_API_KEY
|
||||
value: "sptWL6XFxl4b8"
|
||||
- name: PYTHONUNBUFFERED
|
||||
value: "1"
|
||||
# Google test key
|
||||
- name: RECAPTCHA_PUBLIC_KEY
|
||||
value: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
|
||||
@@ -41,6 +48,8 @@ spec:
|
||||
secretKeyRef:
|
||||
name: miniobucket-inventory-app-owner-secrets
|
||||
key: MINIO_URI
|
||||
- name: SECRET_KEY
|
||||
value: "bad_secret"
|
||||
- name: ENVIRONMENT_TYPE
|
||||
value: "DEV"
|
||||
- name: MY_POD_NAME
|
||||
|
117
inventory-app/api.py
Normal file
117
inventory-app/api.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import re
|
||||
import const
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from pymongo import MongoClient
|
||||
from flask import Blueprint, g, request, jsonify
|
||||
from common import slack_post
|
||||
|
||||
page_api = Blueprint("api", __name__)
|
||||
db = MongoClient(const.MONGO_URI).get_default_database()
|
||||
|
||||
def check_api_key(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
request_key = request.headers.get('Authorization', False)
|
||||
if not request_key:
|
||||
return "nope", 403
|
||||
found_key = re.search(r"Basic (.*)", request_key).group(1)
|
||||
if not found_key or found_key != const.INVENTORY_API_KEY:
|
||||
return "nope", 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@page_api.route("/cards", methods=["POST"])
|
||||
@check_api_key
|
||||
def get_group_cards():
|
||||
request_groups = request.json.get("groups", False)
|
||||
if not request_groups:
|
||||
return "must specify groups in parameter", 400
|
||||
print(f"found {len(g.users)} users for groups: {request_groups}")
|
||||
keys = []
|
||||
for u in g.users:
|
||||
for group in u.groups:
|
||||
if group in request_groups:
|
||||
keys.append(u.username)
|
||||
break
|
||||
print(f"{len(keys)} doorkeys")
|
||||
flt = {
|
||||
"token.uid_hash": {"$exists": True},
|
||||
"inventory.owner.username": {"$in": keys}
|
||||
}
|
||||
prj = {
|
||||
"inventory.owner": True,
|
||||
"token.uid_hash": True
|
||||
}
|
||||
found = []
|
||||
for obj in db.inventory.find(flt, prj):
|
||||
found.append({"token": obj["token"]})
|
||||
fl = list(found)
|
||||
print(f"{len(fl)} doorkey tokens")
|
||||
return jsonify(fl)
|
||||
|
||||
@page_api.route("/api/slack/doorboy", methods=['POST'])
|
||||
def view_slack_doorboy():
|
||||
begin_time = time.perf_counter()
|
||||
if request.form.get("token") != const.SLACK_VERIFICATION_TOKEN:
|
||||
return "Invalid token was supplied"
|
||||
if request.form.get("channel_id") not in ("C01CWPF5H8W", "CDL9H8Q9W"):
|
||||
return "Invalid channel was supplied"
|
||||
command = request.form.get("command")
|
||||
try:
|
||||
door = {
|
||||
"/open-all-doors": "outsidedoors",
|
||||
"/open-back-door": "backdoor",
|
||||
"/open-front-door": "frontdoor",
|
||||
"/open-ground-door": "grounddoor",
|
||||
"/open-workshop-door": "workshopdoor"
|
||||
}[command]
|
||||
except KeyError:
|
||||
return "Invalid command was supplied"
|
||||
|
||||
member = None
|
||||
for user in g.users:
|
||||
if user.slack_id == request.form.get("user_id"):
|
||||
member = user
|
||||
|
||||
if door == "workshopdoor":
|
||||
access_group = "k-space:workshop"
|
||||
else:
|
||||
access_group = "k-space:floor"
|
||||
approved = access_group in member.groups
|
||||
|
||||
doors = [door]
|
||||
if door == "outsidedoors":
|
||||
doors = ["backdoor", "frontdoor", "grounddoor"]
|
||||
|
||||
status = "Permitted" if approved else "Denied"
|
||||
subject = member.display_name
|
||||
|
||||
threading.Thread(target=handle_slack_door_event, args=(doors, approved, member, door, status, subject)).start()
|
||||
|
||||
return_message = "Opening %s for %s" % (door, subject) if approved else "Permission denied"
|
||||
end_time = time.perf_counter()
|
||||
print(f"view_slack_doorboy done in {end_time - begin_time:.4f} seconds")
|
||||
return return_message
|
||||
|
||||
def handle_slack_door_event(doors, approved, member, door, status, subject):
|
||||
begin_time = time.perf_counter()
|
||||
for d in doors:
|
||||
db.eventlog.insert_one({
|
||||
"method": "slack",
|
||||
"approved": approved,
|
||||
"duration": 5,
|
||||
"component": "doorboy",
|
||||
"type": "open-door",
|
||||
"door": d,
|
||||
"member_id": member.username,
|
||||
"member": member.display_name,
|
||||
"timestamp": datetime.utcnow(),
|
||||
})
|
||||
|
||||
msg = "%s %s door access for %s via Slack bot" % (status, door, subject)
|
||||
slack_post(msg, "doorboy")
|
||||
end_time = time.perf_counter()
|
||||
print(f"handle_slack_door_event done in {end_time - begin_time:.4f} seconds")
|
@@ -1,16 +1,17 @@
|
||||
import collections.abc
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
import collections.abc
|
||||
from functools import wraps
|
||||
from typing import List
|
||||
|
||||
import const
|
||||
import requests
|
||||
from bson.objectid import ObjectId
|
||||
from common import read_user
|
||||
from flask import g, request
|
||||
from flask_wtf import FlaskForm
|
||||
from kubernetes import client, config
|
||||
from pymongo import MongoClient
|
||||
from kubernetes import client, config
|
||||
from typing import List
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import const
|
||||
|
||||
devenv = const.ENVIRONMENT_TYPE == "DEV"
|
||||
OIDC_USERS_NAMESPACE = os.getenv("OIDC_USERS_NAMESPACE")
|
||||
@@ -92,6 +93,18 @@ def flatten(d, parent_key='', sep='.'):
|
||||
items.append((new_key, v))
|
||||
return dict(items)
|
||||
|
||||
def slack_post(msg, channel):
|
||||
if devenv:
|
||||
print(f"{channel}: {msg}")
|
||||
return
|
||||
|
||||
channels = {
|
||||
"doorboy": const.SLACK_DOORLOG_CALLBACK,
|
||||
}
|
||||
url = channels.get(channel, const.SLACK_DOORLOG_CALLBACK)
|
||||
|
||||
requests.post(url, json={"text": msg })
|
||||
|
||||
def build_query(base_query, fields=[], sort_fields={}):
|
||||
top_usernames= ['k-space']
|
||||
selectors = []
|
||||
|
@@ -12,8 +12,12 @@ def file_exists(path):
|
||||
|
||||
ENVIRONMENT_TYPE = getenv_in("ENVIRONMENT_TYPE", "DEV", "PROD")
|
||||
|
||||
SECRET_KEY = os.environ["SECRET_KEY"]
|
||||
AWS_S3_ENDPOINT_URL = os.environ["AWS_S3_ENDPOINT_URL"]
|
||||
BUCKET_NAME = os.environ["BUCKET_NAME"]
|
||||
INVENTORY_ASSETS_BASE_URL = os.environ["INVENTORY_ASSETS_BASE_URL"]
|
||||
MONGO_URI = os.environ["MONGO_URI"]
|
||||
SLACK_VERIFICATION_TOKEN = os.environ["SLACK_VERIFICATION_TOKEN"] # used to verify (deprecated) incoming requests from slack
|
||||
SLACK_DOORLOG_CALLBACK = os.environ["SLACK_DOORLOG_CALLBACK"] # used for sending logs to private channel
|
||||
INVENTORY_API_KEY = os.environ["INVENTORY_API_KEY"] # used by doorboy-proxy (@check_api_key)
|
||||
MACADDRESS_OUTLINK_BASEURL = os.environ["MACADDRESS_OUTLINK_BASEURL"]
|
||||
|
@@ -1,18 +1,19 @@
|
||||
from datetime import datetime, timedelta
|
||||
from dateutil.parser import parse, ParserError
|
||||
|
||||
import const
|
||||
import pytz
|
||||
from bson.objectid import ObjectId
|
||||
from common import User
|
||||
from dateutil.parser import ParserError, parse
|
||||
from flask import Blueprint, abort, g, redirect, render_template, request
|
||||
from flask import Blueprint, g, redirect, render_template, request, abort
|
||||
from flask_wtf import FlaskForm
|
||||
from oidc import login_required, read_user
|
||||
from pymongo import MongoClient
|
||||
from wtforms import (BooleanField, IntegerField, SelectField, StringField,
|
||||
validators)
|
||||
from wtforms import StringField, IntegerField, SelectField, BooleanField, DateTimeField, validators
|
||||
from wtforms.validators import DataRequired
|
||||
|
||||
import pytz
|
||||
import const
|
||||
from api import check_api_key
|
||||
from common import slack_post, User
|
||||
from oidc import login_required, read_user
|
||||
|
||||
page_doorboy = Blueprint("doorboy", __name__)
|
||||
db = MongoClient(const.MONGO_URI).get_default_database()
|
||||
|
||||
@@ -27,7 +28,7 @@ def view_doorboy_claim(event_id):
|
||||
})
|
||||
|
||||
# Find token object to associate with user
|
||||
db.inventory.update_one({
|
||||
token = db.inventory.update_one({
|
||||
"type": "token",
|
||||
"token.uid_hash": event["token"]["uid_hash"],
|
||||
"inventory.owner.username": { "$exists": False }
|
||||
@@ -127,27 +128,23 @@ class HoldDoorForm(FlaskForm):
|
||||
door_name = SelectField("Door name", choices=[(j,j) for j in ["grounddoor", "frontdoor", "backdoor"]], validators=[DataRequired()])
|
||||
duration = IntegerField('Duration in seconds', validators=[DataRequired(), validators.NumberRange(min=5, max=21600)])
|
||||
|
||||
# duration=0 to override and close right away
|
||||
@page_doorboy.route("/m/doorboy/hold", methods=["POST"])
|
||||
@login_required
|
||||
def view_doorboy_hold():
|
||||
user = read_user()
|
||||
form = HoldDoorForm(request.form)
|
||||
now = datetime.utcnow()
|
||||
if form.validate_on_submit():
|
||||
db.eventlog.insert_one({
|
||||
"component": "doorboy",
|
||||
"method": "hold",
|
||||
"timestamp": datetime.utcnow(),
|
||||
"type": "hold",
|
||||
"requester": user["name"],
|
||||
"door": form.door_name.data,
|
||||
"approved": True,
|
||||
"user": {
|
||||
"id": user["username"],
|
||||
"name": user["name"]
|
||||
},
|
||||
"expires": datetime.utcnow() + timedelta(seconds=form.duration.data)
|
||||
})
|
||||
return redirect("/m/doorboy")
|
||||
|
||||
|
||||
@page_doorboy.route("/m/doorboy/<door>/open")
|
||||
@login_required
|
||||
def view_doorboy_open(door):
|
||||
@@ -161,22 +158,42 @@ def view_doorboy_open(door):
|
||||
access_group = "k-space:floor"
|
||||
approved = access_group in g.users_lookup.get(user["username"], User()).groups
|
||||
db.eventlog.insert_one({
|
||||
"component": "doorboy",
|
||||
"method": "web",
|
||||
"timestamp": datetime.utcnow(),
|
||||
"door": door,
|
||||
"approved": approved,
|
||||
"user": {
|
||||
"id": user["username"],
|
||||
"name": user["name"]
|
||||
}
|
||||
"duration": 5,
|
||||
"component": "doorboy",
|
||||
"type": "open-door",
|
||||
"door": door,
|
||||
"member_id": user["username"],
|
||||
"member": user["name"],
|
||||
"timestamp": datetime.utcnow(),
|
||||
})
|
||||
|
||||
status = "Permitted" if approved else "Denied"
|
||||
subject = user["name"]
|
||||
msg = "%s %s door access for %s via https://inventory.k-space.ee/m/doorboy" % (status, door, subject)
|
||||
slack_post(msg, "doorboy")
|
||||
|
||||
if approved:
|
||||
return redirect("/m/doorboy")
|
||||
else:
|
||||
return "", 401
|
||||
|
||||
|
||||
@page_doorboy.route("/m/doorboy/slam", methods=["POST"])
|
||||
@login_required
|
||||
def view_doorboy_slam():
|
||||
user = read_user()
|
||||
db.eventlog.insert_one({
|
||||
"component": "doorboy",
|
||||
"type": "hold",
|
||||
"requester": user["name"],
|
||||
"door": form.door_name.data,
|
||||
"expires": datetime.utcnow() + timedelta(minutes=form.duration_min.data)
|
||||
})
|
||||
return redirect("/m/doorboy")
|
||||
|
||||
|
||||
@page_doorboy.route("/m/doorboy")
|
||||
@login_required
|
||||
def view_doorboy():
|
||||
@@ -278,3 +295,73 @@ def view_doorboy_token_events(token_id):
|
||||
token = db.inventory.find_one({"_id": ObjectId(token_id)})
|
||||
latest_events = db.eventlog.find({"component": "doorboy", "event":"card-swiped", "token.uid_hash": token.get("token").get("uid_hash")}).sort([("timestamp", -1)])
|
||||
return render_template("doorboy.html", **locals())
|
||||
|
||||
class FormSwipe(FlaskForm):
|
||||
class Meta:
|
||||
csrf = False
|
||||
uid = StringField('uid', validators=[])
|
||||
uid_hash = StringField('uid', validators=[])
|
||||
door = StringField('door', validators=[DataRequired()])
|
||||
success = BooleanField('success', validators=[])
|
||||
timestamp = DateTimeField('timestamp')
|
||||
|
||||
@page_doorboy.route("/m/doorboy/swipe", methods=["POST"])
|
||||
@check_api_key
|
||||
def view_swipe():
|
||||
form = request.json
|
||||
print(form)
|
||||
timestamp = parse(form["timestamp"]) if form.get("timestamp") else None
|
||||
now = datetime.utcnow()
|
||||
# Make sure token exists
|
||||
db.inventory.update_one({
|
||||
"type": "token",
|
||||
"component": "doorboy",
|
||||
"token.uid_hash": form["uid_hash"]
|
||||
}, {
|
||||
"$set": {
|
||||
"last_seen": timestamp or now
|
||||
},
|
||||
"$setOnInsert": {
|
||||
"component": "doorboy",
|
||||
"type": "token",
|
||||
"first_seen": now,
|
||||
"inventory": {
|
||||
"claimable": True,
|
||||
}
|
||||
}
|
||||
}, upsert=True)
|
||||
|
||||
# Fetch token to read owner
|
||||
token = db.inventory.find_one({
|
||||
"type": "token",
|
||||
"component": "doorboy",
|
||||
"token.uid_hash": form["uid_hash"]
|
||||
})
|
||||
|
||||
event_swipe = {
|
||||
"component": "doorboy",
|
||||
"timestamp": timestamp,
|
||||
"door": form["door"],
|
||||
"event": "card-swiped",
|
||||
"success": form["success"],
|
||||
"token": {
|
||||
"uid_hash": form["uid_hash"]
|
||||
},
|
||||
"inventory": {}
|
||||
}
|
||||
|
||||
if token.get("inventory", {}).get("owner", {}).get("username", None):
|
||||
event_swipe["inventory"]["owner_id"] = token["inventory"]["owner"]["username"]
|
||||
|
||||
db.eventlog.insert_one(event_swipe)
|
||||
|
||||
status = "Permitted" if form["success"] else "Denied"
|
||||
username = token.get("inventory", {}).get("owner", {}).get("username", None)
|
||||
if username and username in g.users_lookup:
|
||||
subject = g.users_lookup[username].display_name or username
|
||||
else:
|
||||
subject = "Unknown"
|
||||
msg = "%s %s door access for %s identified by keycard/keyfob" % (status, form["door"], subject)
|
||||
slack_post(msg, "doorboy")
|
||||
|
||||
return "ok"
|
||||
|
@@ -216,6 +216,8 @@ def save_inventory_item(item_id=None, **_):
|
||||
d = {}
|
||||
form.populate_dict(d)
|
||||
d['tags'] = list(set(request.form.getlist('tags[]')))
|
||||
if d.get('location'):
|
||||
d['location_code'] = render_location_link(d['location'])
|
||||
custom_errors = {}
|
||||
try:
|
||||
if item_id:
|
||||
@@ -256,6 +258,16 @@ def save_inventory_item(item_id=None, **_):
|
||||
return render_template("inventory_edit.html", **locals())
|
||||
return redirect("/m/inventory/%s/view" % item_id)
|
||||
|
||||
def render_location_link(location: str):
|
||||
if location == "":
|
||||
return
|
||||
|
||||
linkstart = location.find("k6.ee/")
|
||||
if linkstart == -1:
|
||||
return
|
||||
|
||||
return location[linkstart:].split(" ", 1)[0].split("/", 1)[1]
|
||||
|
||||
def is_image_ext(filename):
|
||||
return '.' in filename and \
|
||||
filename.rsplit('.', 1)[1].lower() in ["jpg", "jpeg"]
|
||||
@@ -598,3 +610,18 @@ def view_inventory_vacate(item_id):
|
||||
},
|
||||
})
|
||||
return redirect("/m/inventory/%s/view" % item_id)
|
||||
|
||||
@page_inventory.route("/m/inventory/contains", methods=["GET"])
|
||||
@login_required(groups=["k-space:inventory:audit"])
|
||||
def get_contains():
|
||||
slug = request.args.get("slug")
|
||||
if not slug:
|
||||
return []
|
||||
|
||||
return list(db.inventory.find({
|
||||
"location_code": slug
|
||||
}, {
|
||||
"_id": 0,
|
||||
"shortener.slug": 1,
|
||||
"name": 1
|
||||
}))
|
||||
|
@@ -25,8 +25,9 @@ from wtforms import (
|
||||
import const
|
||||
from common import devenv, format_name, get_users, User
|
||||
from inventory import page_inventory
|
||||
from oidc import page_oidc, login_required
|
||||
from oidc import page_oidc, login_required, read_user
|
||||
from doorboy import page_doorboy
|
||||
from api import page_api
|
||||
|
||||
def check_foreign_key_format(item):
|
||||
owner = item.get("inventory", {}).get("owner", {})
|
||||
@@ -123,9 +124,12 @@ app = Flask(__name__)
|
||||
app.wsgi_app = ReverseProxied(app.wsgi_app)
|
||||
app.register_blueprint(page_inventory)
|
||||
app.register_blueprint(page_oidc)
|
||||
app.register_blueprint(page_api)
|
||||
app.register_blueprint(page_doorboy)
|
||||
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)
|
||||
|
@@ -84,7 +84,7 @@ Does not include door opens by webhook.
|
||||
<td>{{ o.door }}</td>
|
||||
<td>{% if o.inventory and o.inventory.owner %}<a href="/m/user/{{ o.inventory.owner.username }}">{{ o.inventory.owner.username | display_name }}</a>{% else %}Unknown{% endif %}</td>
|
||||
<td><a href="/m/doorboy/{{ o._id }}/events">{{ o.token.uid_hash[-6:] }}</a></td>
|
||||
<!-- <td>{% if o.approved %}<i class="material-icons">check_circle</i>{% else %} {% endif %}</td> -->
|
||||
<!-- <td>{% if o.success %}<i class="material-icons">check_circle</i>{% else %} {% endif %}</td> -->
|
||||
<td>{% if o.inventory and o.inventory.owner %}{{ o.token.comment }}{% else %}<a class="waves-effect waves-light btn" href="/m/doorboy/{{ o._id }}/claim">Claim keycard</a>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
@@ -136,6 +136,15 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="browser-default" id="contains">
|
||||
<h3>Contains</h3>
|
||||
<ul class="browser-default">
|
||||
<li class="closed browser-default" data-name="{{ item.name }}" data-slug="{{ item.get("shortener", {}).get("slug") }}">
|
||||
This item
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -170,6 +179,82 @@ $(function() {
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
function slugLink(slug) {
|
||||
if (!slug) {
|
||||
return "";
|
||||
}
|
||||
var href = "k6.ee/" + slug;
|
||||
return $("<a/>").attr("href", "https://" + href).text(" " + href);
|
||||
}
|
||||
|
||||
function expandLocation(e) {
|
||||
e.stopPropagation();
|
||||
var cur = $(this);
|
||||
var slug = cur.data("slug");
|
||||
var name = cur.data("name");
|
||||
if (cur.hasClass("empty")) {
|
||||
return;
|
||||
}
|
||||
if (!cur.hasClass("closed")) {
|
||||
cur.empty();
|
||||
cur.text(name);
|
||||
cur.append(slugLink(slug));
|
||||
cur.addClass("closed");
|
||||
return;
|
||||
}
|
||||
|
||||
var expand = $("<ul/>").data("slug", slug);
|
||||
expand.attr("class", "browser-default");
|
||||
$.get("/m/inventory/contains", { slug }, function(data) {
|
||||
cur.removeClass("closed");
|
||||
if (!data.length) {
|
||||
cur.addClass("empty");
|
||||
return;
|
||||
}
|
||||
$.each(data, function(k, v) {
|
||||
var inner = $("<li/>").data("slug", v.shortener.slug);
|
||||
inner.text(v.name);
|
||||
inner.data("name", v.name);
|
||||
inner.on("click", expandLocation);
|
||||
inner.attr("class", "closed browser-default");
|
||||
inner.append(slugLink(v.shortener.slug));
|
||||
expand.append(inner);
|
||||
});
|
||||
cur.html(expand);
|
||||
cur.prepend(slugLink(slug));
|
||||
cur.prepend(name);
|
||||
});
|
||||
}
|
||||
var contains = $("div#contains li");
|
||||
contains.on("click", expandLocation);
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
div#contains {
|
||||
padding-bottom: 6em;
|
||||
}
|
||||
|
||||
div#contains ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
div#contains li {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
div#contains li::before {
|
||||
content: "▼";
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
div#contains li.closed::before {
|
||||
content: "►";
|
||||
}
|
||||
|
||||
div#contains li.empty::before {
|
||||
content: "";
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
Reference in New Issue
Block a user