From 9df8b02dfaeec4918a856849571b9381f98aaf4a Mon Sep 17 00:00:00 2001 From: Sam Willcocks Date: Fri, 10 May 2024 18:11:41 +0100 Subject: [PATCH 01/30] Make redis session store optional --- frontend.py | 5 ++++- redis_session.py | 4 ++-- settings.example.toml | 3 +++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend.py b/frontend.py index 82c37da..19fbc45 100644 --- a/frontend.py +++ b/frontend.py @@ -39,6 +39,7 @@ __name__, static_folder=CONFIG.get('STATIC_PATH', 'static'), ) +app.secret_key = CONFIG.get('URL_KEY') app.wsgi_app = ProxyFix(app.wsgi_app) for copy_key in ( @@ -55,7 +56,9 @@ github = GitHub(app) -app.session_interface = RedisSessionStore() + +if CONFIG.get("REDIS_HOST"): + app.session_interface = RedisSessionStore(host=CONFIG.get("REDIS_HOST")) def cached_asset_name(asset): diff --git a/redis_session.py b/redis_session.py index 94221f2..47655df 100644 --- a/redis_session.py +++ b/redis_session.py @@ -18,8 +18,8 @@ def on_update(self): class RedisSessionStore(sessions.SessionInterface): - def __init__(self): - self.redis = Redis() + def __init__(self, host): + self.redis = Redis(host=host) def open_session(self, app, request): sid = request.cookies.get(app.session_cookie_name) diff --git a/settings.example.toml b/settings.example.toml index aa586f9..e812b1d 100644 --- a/settings.example.toml +++ b/settings.example.toml @@ -28,6 +28,9 @@ SETUP_IDS = [ # urls send to moderators. URL_KEY = 'reallysecure' +# Uncomment to use redis as a session store +# REDIS_HOST = 'localhost' + # Push notifications for moderation requests to an mqtt server. MQTT_SERVER = '127.0.0.1' #MQTT_USERNAME = '' From a433c4645622309e7ba1892cc09fbe98b9d4e2cc Mon Sep 17 00:00:00 2001 From: Sam Willcocks Date: Fri, 10 May 2024 18:12:06 +0100 Subject: [PATCH 02/30] Replace utcnow use --- helper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helper.py b/helper.py index c44ad96..7b12f1f 100644 --- a/helper.py +++ b/helper.py @@ -30,7 +30,7 @@ def get_user_assets(): def get_all_live_assets(no_time_filter=False): - now = int(datetime.utcnow().timestamp()) + now = int(datetime.now().timestamp()) assets = ib.get("asset/list")["assets"] return [ asset @@ -51,7 +51,7 @@ def login_disabled_for_user(user=None): if user and user.lower() in CONFIG.get("ADMIN_USERS", set()): return False - now = datetime.utcnow().timestamp() + now = datetime.now().timestamp() return not (CONFIG["TIME_MIN"] < now < CONFIG["TIME_MAX"]) From 58c49d6dd53cb4c902e2a16e277456a73bfe39ff Mon Sep 17 00:00:00 2001 From: Sam Willcocks Date: Fri, 10 May 2024 18:12:17 +0100 Subject: [PATCH 03/30] Update all the deps --- redis_session.py | 2 +- requirements.txt | 50 +++++++++++++++++++++++++----------------------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/redis_session.py b/redis_session.py index 47655df..76ab069 100644 --- a/redis_session.py +++ b/redis_session.py @@ -22,7 +22,7 @@ def __init__(self, host): self.redis = Redis(host=host) def open_session(self, app, request): - sid = request.cookies.get(app.session_cookie_name) + sid = request.cookies.get(app.config["SESSION_COOKIE_NAME"]) if not sid: return RedisSession() data = self.redis.get(f"sid:{sid}") diff --git a/requirements.txt b/requirements.txt index ed6dff4..f0f559f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,26 +1,28 @@ -certifi==2021.10.8 -charset-normalizer==2.0.9 -click==8.0.3 -Deprecated==1.2.13 -Flask==2.0.2 -gevent==21.12.0 +async-timeout==4.0.3 +blinker==1.8.2 +certifi==2024.2.2 +charset-normalizer==3.3.2 +click==8.1.7 +Deprecated==1.2.14 +Flask==3.0.3 +gevent==24.2.1 GitHub-Flask==3.2.0 -greenlet==1.1.2 -gunicorn==20.1.0 -httplib2==0.20.2 -idna==3.3 -iso8601==1.0.2 -itsdangerous==2.0.1 -Jinja2==3.0.3 -MarkupSafe==2.0.1 +greenlet==3.0.3 +gunicorn==22.0.0 +httplib2==0.22.0 +idna==3.7 +iso8601==2.1.0 +itsdangerous==2.2.0 +Jinja2==3.1.4 +MarkupSafe==2.1.5 oauth2==1.9.0.post1 -paho-mqtt==1.6.1 -pyparsing==3.0.6 -redis==4.0.2 -requests==2.26.0 -urllib3==1.26.7 -Werkzeug==2.0.2 -wrapt==1.13.3 -zope.event==4.5.0 -zope.interface==5.4.0 -rtoml;python_version<'3.11' +packaging==24.0 +paho-mqtt==2.1.0 +pyparsing==3.1.2 +redis==5.0.4 +requests==2.31.0 +urllib3==2.2.1 +Werkzeug==3.0.3 +wrapt==1.16.0 +zope.event==5.0 +zope.interface==6.3 From 57b096f49976cf8245f57ca96c460274dc86c642 Mon Sep 17 00:00:00 2001 From: Sam Willcocks Date: Mon, 13 May 2024 12:11:43 +0100 Subject: [PATCH 04/30] Update faq for emf, add link to gh repo --- templates/faq.jinja | 15 ++++++++------- templates/layout.jinja | 3 +++ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/templates/faq.jinja b/templates/faq.jinja index 9baa42c..779621c 100644 --- a/templates/faq.jinja +++ b/templates/faq.jinja @@ -15,9 +15,9 @@

What can I upload?

Please submit something actionable (there must be a assembly/village - name/eventphone number, anything!) and with a single purpose like - one project, an interesting service you're running this event, a - talk by you or side events like key signing parties or meetups. + name/DECT number/website, anything!) and with a single purpose like + a project, an interesting service you're running this event, a + talk by you or a community event or or meetup. Your submission must include information on how to reach you on or during this event, so anyone interested can meet or talk to you @@ -78,10 +78,11 @@

Where can I get in contact?

- Please use the IRC - Channel #infobeamer on irc.hackint.org (also - bridged to matrix) - or #info-beamer on the cccv rocketchat instance. + You can contact us at/on: +

{% endblock %} diff --git a/templates/layout.jinja b/templates/layout.jinja index d27384b..6ec6cb6 100644 --- a/templates/layout.jinja +++ b/templates/layout.jinja @@ -59,6 +59,9 @@ Share your projects via info-beamer. FAQ / Contact.

+

+ Powered by infobeamer-content-cms +

Screens run info-beamer on Raspberry Pis.

From 74157c56b49ec3183006a368d705df726d5dc299 Mon Sep 17 00:00:00 2001 From: Sam Willcocks Date: Mon, 13 May 2024 13:08:06 +0100 Subject: [PATCH 05/30] Add user admin helper --- frontend.py | 9 +++++---- helper.py | 5 ++++- syncer.py | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend.py b/frontend.py index 19fbc45..9dd5e24 100644 --- a/frontend.py +++ b/frontend.py @@ -30,6 +30,7 @@ get_user_assets, login_disabled_for_user, mk_sig, + user_is_admin, ) from ib_hosted import get_scoped_api_key, ib, update_asset_userdata from redis_session import RedisSessionStore @@ -208,7 +209,7 @@ def content_upload(): session["redirect_after_login"] = request.url return redirect(url_for("login")) - if g.user.lower() not in CONFIG.get("ADMIN_USERS", set()): + if not user_is_admin(g.user): max_uploads = CONFIG["MAX_UPLOADS"] if len(get_user_assets()) >= max_uploads: return error("You have reached your upload limit") @@ -284,7 +285,7 @@ def content_request_review(asset_id): if "state" in asset["userdata"]: # not in new state? return error("Cannot review") - if g.user.lower() in CONFIG.get("ADMIN_USERS", set()): + if user_is_admin(g.user): update_asset_userdata(asset, state="confirmed") app.logger.warn( "auto-confirming {} because it was uploaded by admin {}".format( @@ -324,7 +325,7 @@ def content_moderate(asset_id, sig): if not g.user: session["redirect_after_login"] = request.url return redirect(url_for("login")) - elif g.user.lower() not in CONFIG.get("ADMIN_USERS", set()): + elif not user_is_admin(g.user): app.logger.warning(f"request to moderate {asset_id} by non-admin user {g.user}") abort(401) @@ -369,7 +370,7 @@ def content_moderate_result(asset_id, sig, result): if not g.user: session["redirect_after_login"] = request.url return redirect(url_for("login")) - elif g.user.lower() not in CONFIG.get("ADMIN_USERS", set()): + elif not user_is_admin(g.user): app.logger.warning(f"request to moderate {asset_id} by non-admin user {g.user}") abort(401) diff --git a/helper.py b/helper.py index 7b12f1f..25bf5b4 100644 --- a/helper.py +++ b/helper.py @@ -11,6 +11,9 @@ def error(msg): return jsonify(error=msg), 400 +def user_is_admin(user) -> bool: + return user is not None and user.lower() in CONFIG.get("ADMIN_USERS", set()) + def get_user_assets(): assets = ib.get("asset/list")["assets"] @@ -48,7 +51,7 @@ def get_all_live_assets(no_time_filter=False): def login_disabled_for_user(user=None): - if user and user.lower() in CONFIG.get("ADMIN_USERS", set()): + if user_is_admin(user): return False now = datetime.now().timestamp() diff --git a/syncer.py b/syncer.py index 3ffa457..13fa6d7 100644 --- a/syncer.py +++ b/syncer.py @@ -3,7 +3,7 @@ from logging import getLogger from conf import CONFIG -from helper import get_all_live_assets +from helper import get_all_live_assets, user_is_admin from ib_hosted import ib from voc_mqtt import send_message @@ -72,7 +72,7 @@ def asset_to_tiles(asset): "config": {"fade_time": 0.5}, } ) - if asset["userdata"]["user"].lower() not in CONFIG.get("ADMIN_USERS", set()): + if not user_is_admin(asset["userdata"]["user"]): tiles.append( { "type": "flat", From abdb5d7e99b847a6cb2ad83fc4be9c5ae0b37d36 Mon Sep 17 00:00:00 2001 From: Sam Willcocks Date: Wed, 15 May 2024 16:26:17 +0100 Subject: [PATCH 06/30] Add unmoderated view on homepage Remove signatures - users need to be signed in and an admin anyway, so we don't need them Show unmoderated submissions on index page. Allow admins to re-moderate previously approved submissions Store who moderated a submission and display it to admins --- frontend.py | 85 ++++++++++++---------------------------- helper.py | 66 ++++++++++++++++++++++++++++--- static/dashboard.js | 2 +- static/index.js | 28 ++++++++++++- static/moderate.js | 4 +- templates/index.jinja | 8 +++- templates/moderate.jinja | 2 +- 7 files changed, 121 insertions(+), 74 deletions(-) diff --git a/frontend.py b/frontend.py index 9dd5e24..3ee1e9b 100644 --- a/frontend.py +++ b/frontend.py @@ -1,8 +1,5 @@ -import os import random -import shutil import socket -import tempfile from datetime import datetime from secrets import token_hex @@ -24,12 +21,15 @@ from conf import CONFIG from helper import ( + admin_required, + cached_asset_name, error, get_all_live_assets, + get_assets_awaiting_moderation, get_random, get_user_assets, login_disabled_for_user, - mk_sig, + make_asset_json, user_is_admin, ) from ib_hosted import get_scoped_api_key, ib, update_asset_userdata @@ -62,31 +62,10 @@ app.session_interface = RedisSessionStore(host=CONFIG.get("REDIS_HOST")) -def cached_asset_name(asset): - asset_id = asset["id"] - filename = "asset-{}.{}".format( - asset_id, - "jpg" if asset["filetype"] == "image" else "mp4", - ) - cache_name = os.path.join(CONFIG.get('STATIC_PATH', 'static'), filename) - - if not os.path.exists(cache_name): - app.logger.info(f"fetching {asset_id} to {cache_name}") - dl = ib.get(f"asset/{asset_id}/download") - r = requests.get(dl["download_url"], stream=True, timeout=5) - r.raise_for_status() - with tempfile.NamedTemporaryFile(delete=False) as f: - shutil.copyfileobj(r.raw, f) - shutil.move(f.name, cache_name) - os.chmod(cache_name, 0o664) - del r - - return filename - - @app.before_request def before_request(): user = session.get("gh_login") + g.user_is_admin = user_is_admin(user) if login_disabled_for_user(user): g.user = None @@ -115,8 +94,6 @@ def authorized(access_token): if login_disabled_for_user(github_user["login"]): return render_template("time_error.jinja") - # app.logger.debug(github_user) - age = datetime.utcnow() - iso8601.parse_date(github_user["created_at"]).replace( tzinfo=None ) @@ -202,6 +179,11 @@ def content_list(): assets=assets, ) +@app.route("/content/awaiting_moderation") +@admin_required +def content_awaiting_moderation(): + return make_asset_json(get_assets_awaiting_moderation(), mod_data=True) + @app.route("/content/upload", methods=["POST"]) def content_upload(): @@ -209,7 +191,7 @@ def content_upload(): session["redirect_after_login"] = request.url return redirect(url_for("login")) - if not user_is_admin(g.user): + if not g.user_is_admin: max_uploads = CONFIG["MAX_UPLOADS"] if len(get_user_assets()) >= max_uploads: return error("You have reached your upload limit") @@ -285,7 +267,7 @@ def content_request_review(asset_id): if "state" in asset["userdata"]: # not in new state? return error("Cannot review") - if user_is_admin(g.user): + if g.user_is_admin: update_asset_userdata(asset, state="confirmed") app.logger.warn( "auto-confirming {} because it was uploaded by admin {}".format( @@ -295,7 +277,7 @@ def content_request_review(asset_id): return jsonify(ok=True) moderation_url = url_for( - "content_moderate", asset_id=asset_id, sig=mk_sig(asset_id), _external=True + "content_moderate", asset_id=asset_id, _external=True ) assert ( @@ -315,17 +297,13 @@ def content_request_review(asset_id): return jsonify(ok=True) -@app.route("/content/moderate/-") -def content_moderate(asset_id, sig): - if sig != mk_sig(asset_id): - app.logger.info( - f"request to moderate asset {asset_id} rejected because of missing or wrong signature" - ) - abort(404) +@app.route("/content/moderate/") +@admin_required +def content_moderate(asset_id): if not g.user: session["redirect_after_login"] = request.url return redirect(url_for("login")) - elif not user_is_admin(g.user): + elif not g.user_is_admin: app.logger.warning(f"request to moderate {asset_id} by non-admin user {g.user}") abort(401) @@ -353,24 +331,19 @@ def content_moderate(asset_id, sig): "url": url_for("static", filename=cached_asset_name(asset)), "state": state, }, - sig=mk_sig(asset_id), ) @app.route( - "/content/moderate/-/", + "/content/moderate//", methods=["POST"], ) -def content_moderate_result(asset_id, sig, result): - if sig != mk_sig(asset_id): - app.logger.info( - f"request to moderate asset {asset_id} rejected because of missing or wrong signature" - ) - abort(404) +@admin_required +def content_moderate_result(asset_id, result): if not g.user: session["redirect_after_login"] = request.url return redirect(url_for("login")) - elif not user_is_admin(g.user): + elif not g.user_is_admin: app.logger.warning(f"request to moderate {asset_id} by non-admin user {g.user}") abort(401) @@ -391,10 +364,10 @@ def content_moderate_result(asset_id, sig, result): if result == "confirm": app.logger.info("Asset {} was confirmed".format(asset["id"])) - update_asset_userdata(asset, state="confirmed") + update_asset_userdata(asset, state="confirmed", moderated_by=g.user) else: app.logger.info("Asset {} was rejected".format(asset["id"])) - update_asset_userdata(asset, state="rejected") + update_asset_userdata(asset, state="rejected", moderated_by=g.user) return jsonify(ok=True) @@ -453,17 +426,7 @@ def content_live(): no_time_filter = request.values.get("all") assets = get_all_live_assets(no_time_filter=no_time_filter) random.shuffle(assets) - resp = jsonify( - assets=[ - { - "user": asset["userdata"]["user"], - "filetype": asset["filetype"], - "thumb": asset["thumb"], - "url": url_for("static", filename=cached_asset_name(asset)), - } - for asset in assets - ] - ) + resp = make_asset_json(assets, mod_data=g.user_is_admin) resp.headers["Cache-Control"] = "public, max-age=30" return resp diff --git a/helper.py b/helper.py index 25bf5b4..511835b 100644 --- a/helper.py +++ b/helper.py @@ -1,8 +1,12 @@ -import hmac +import os import random from datetime import datetime +from functools import wraps +import shutil +import tempfile -from flask import g, jsonify +from flask import abort, current_app, g, jsonify, url_for +import requests from conf import CONFIG from ib_hosted import ib @@ -14,6 +18,13 @@ def error(msg): def user_is_admin(user) -> bool: return user is not None and user.lower() in CONFIG.get("ADMIN_USERS", set()) +def admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not g.user_is_admin: + abort(401) + return f(*args, **kwargs) + return decorated_function def get_user_assets(): assets = ib.get("asset/list")["assets"] @@ -31,6 +42,14 @@ def get_user_assets(): and asset["userdata"].get("state") != "deleted" ] +def get_assets_awaiting_moderation(): + assets = ib.get("asset/list")["assets"] + return [ + asset + for asset in assets + if asset["userdata"].get("user") and asset["userdata"].get("state") == None + ] + def get_all_live_assets(no_time_filter=False): now = int(datetime.now().timestamp()) @@ -62,7 +81,42 @@ def get_random(size=16): return "".join("%02x" % random.getrandbits(8) for i in range(size)) -def mk_sig(value): - return hmac.new( - CONFIG["URL_KEY"].encode(), str(value).encode(), digestmod="sha256" - ).hexdigest() +def make_asset_json(assets, mod_data=False): + return jsonify( + assets=[ + { + "user": asset["userdata"]["user"], + "filetype": asset["filetype"], + "thumb": asset["thumb"], + "url": url_for("static", filename=cached_asset_name(asset)), + } | ({ + "moderate_url": url_for( + "content_moderate", asset_id=asset["id"], _external=True + ), + "moderated_by": asset["userdata"].get("moderated_by"), + } if mod_data else {}) + for asset in assets + ] + ) + + +def cached_asset_name(asset): + asset_id = asset["id"] + filename = "asset-{}.{}".format( + asset_id, + "jpg" if asset["filetype"] == "image" else "mp4", + ) + cache_name = os.path.join(CONFIG.get('STATIC_PATH', 'static'), filename) + + if not os.path.exists(cache_name): + current_app.logger.info(f"fetching {asset_id} to {cache_name}") + dl = ib.get(f"asset/{asset_id}/download") + r = requests.get(dl["download_url"], stream=True, timeout=5) + r.raise_for_status() + with tempfile.NamedTemporaryFile(delete=False) as f: + shutil.copyfileobj(r.raw, f) + shutil.move(f.name, cache_name) + os.chmod(cache_name, 0o664) + del r + + return filename \ No newline at end of file diff --git a/static/dashboard.js b/static/dashboard.js index 53e01f7..58f33c1 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -222,7 +222,7 @@ Vue.component('asset-box', { const zfill = v => (v+'').length <= 1 ? '0' + v : v for (let ts = start; ts <= end; ts += 3600) { const date = new Date(ts * 1000) - const text_string = zfill(date.getDate()) + '.' + zfill(date.getMonth()+1) + '. - ' + + const text_string = zfill(date.getDate()) + '.' + zfill(date.getMonth()+1) + '. - ' + zfill(date.getHours()) + ':00' options.push([ts, text_string]) } diff --git a/static/index.js b/static/index.js index 21e8b50..6b4054c 100644 --- a/static/index.js +++ b/static/index.js @@ -10,6 +10,8 @@ Vue.component('asset-preview', { +

Moderated by: {{asset.moderated_by}}

+ Moderate `, @@ -31,7 +33,7 @@ Vue.component('list-last', { {{proof.user}}, - {{now - proof.shown|floor}}s ago + {{now - proof.shown|floor}}s ago

@@ -84,4 +86,28 @@ Vue.component('list-active', { } }) +Vue.component('list-unmoderated', { + template: ` +
+ +
+
+ None. +
+
+
+ `, + data: () => ({ + assets: [], + }), + async created() { + const r = await Vue.http.get('content/awaiting_moderation') + this.assets = r.data.assets + } +}) + new Vue({el: "#main"}) diff --git a/static/moderate.js b/static/moderate.js index a3cb434..7352a18 100644 --- a/static/moderate.js +++ b/static/moderate.js @@ -39,12 +39,12 @@ Vue.component('moderate', { needs_moderation: true, completed: false, }), - props: ['sig', 'asset'], + props: ['asset'], methods: { async moderate(result) { this.needs_moderation = false await Vue.nextTick() - await Vue.http.post(`/content/moderate/${this.asset.id}-${this.sig}/${result}`) + await Vue.http.post(`/content/moderate/${this.asset.id}/${result}`) this.completed = true }, }, diff --git a/templates/index.jinja b/templates/index.jinja index 178131d..8849015 100644 --- a/templates/index.jinja +++ b/templates/index.jinja @@ -5,9 +5,13 @@

Share your projects on the streams and during breaks.

You can upload short videos (max 10 seconds) or images to show your project, installation, talk, meeting or anything else - that might be interesting to other visitors. + that might be interesting to other visitors.

-

Projects submitted

+ {% if g.user_is_admin %} +

Awaiting Moderation

+ + {% endif %} +

All Projects


{% endblock %} diff --git a/templates/moderate.jinja b/templates/moderate.jinja index 5d75863..575043e 100644 --- a/templates/moderate.jinja +++ b/templates/moderate.jinja @@ -3,7 +3,7 @@ {% block body %}

Moderation

- + {% endblock %} {% block js %} From b7e54fd489de834bfb900460c20654c92bfd6e8e Mon Sep 17 00:00:00 2001 From: Sam Willcocks Date: Wed, 15 May 2024 17:47:48 +0100 Subject: [PATCH 07/30] Add prometheus metrics --- frontend.py | 26 ++++++++++++++++- helper.py | 73 ++++++++++++++++++++++++++++-------------------- requirements.txt | 1 + 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/frontend.py b/frontend.py index 3ee1e9b..5b8c13a 100644 --- a/frontend.py +++ b/frontend.py @@ -1,10 +1,14 @@ +from collections import defaultdict import random import socket from datetime import datetime from secrets import token_hex +from typing import Iterable import iso8601 -import requests +from prometheus_client.metrics_core import Metric +from prometheus_client.core import GaugeMetricFamily, REGISTRY +from prometheus_client.registry import Collector from flask import ( Flask, abort, @@ -17,6 +21,7 @@ url_for, ) from flask_github import GitHub +from prometheus_client import generate_latest from werkzeug.middleware.proxy_fix import ProxyFix from conf import CONFIG @@ -25,6 +30,7 @@ cached_asset_name, error, get_all_live_assets, + get_assets, get_assets_awaiting_moderation, get_random, get_user_assets, @@ -56,6 +62,19 @@ socket.setdefaulttimeout(3) # for mqtt +class SubmissionsCollector(Collector): + def collect(self) -> Iterable[Metric]: + counts = defaultdict(int) + for a in get_assets(): + counts[a.state] += 1 + g = GaugeMetricFamily("submissions", "Counts of content submissions", labels=["state"]) + for s, c in counts.items(): + g.add_metric([s], c) + yield g + + +REGISTRY.register(SubmissionsCollector()) + github = GitHub(app) if CONFIG.get("REDIS_HOST"): @@ -431,6 +450,11 @@ def content_live(): return resp +@app.route("/metrics") +def metrics(): + return generate_latest() + + # @app.route("/content/last") # def content_last(): # assets = get_all_live_assets() diff --git a/helper.py b/helper.py index 511835b..30d7eed 100644 --- a/helper.py +++ b/helper.py @@ -4,6 +4,7 @@ from functools import wraps import shutil import tempfile +from typing import Iterable, NamedTuple, Optional from flask import abort, current_app, g, jsonify, url_for import requests @@ -26,44 +27,56 @@ def decorated_function(*args, **kwargs): return f(*args, **kwargs) return decorated_function -def get_user_assets(): +class Asset(NamedTuple): + id: str + filetype: str + thumb: str + state: str + user: str + starts: Optional[str] + ends: Optional[str] + moderate_url: Optional[str] = None + moderated_by: Optional[str] = None + +def get_assets(): assets = ib.get("asset/list")["assets"] return [ - { - "id": asset["id"], - "filetype": asset["filetype"], - "thumb": asset["thumb"], - "state": asset["userdata"].get("state", "new"), - "starts": asset["userdata"].get("starts"), - "ends": asset["userdata"].get("ends"), - } - for asset in assets - if asset["userdata"].get("user") == g.user - and asset["userdata"].get("state") != "deleted" + Asset( + id=asset["id"], + filetype=asset["filetype"], + thumb=asset["thumb"], + user=asset["userdata"]["user"], + state=asset["userdata"].get("state", "new"), + starts=asset["userdata"].get("starts"), + ends=asset["userdata"].get("ends"), + ) for asset in assets if asset["userdata"].get("user") != None + ] + +def get_user_assets(): + return [ + a for a in get_assets() + if a.user == g.user and a.state != "deleted" ] def get_assets_awaiting_moderation(): - assets = ib.get("asset/list")["assets"] return [ asset - for asset in assets - if asset["userdata"].get("user") and asset["userdata"].get("state") == None + for asset in get_assets() + if asset.state == "new" ] def get_all_live_assets(no_time_filter=False): now = int(datetime.now().timestamp()) - assets = ib.get("asset/list")["assets"] return [ asset - for asset in assets - if asset["userdata"].get("state") in ("confirmed",) - and asset["userdata"].get("user") is not None + for asset in get_assets() + if asset.state in ("confirmed",) and ( no_time_filter or ( - (asset["userdata"].get("starts") or now) <= now - and (asset["userdata"].get("ends") or now) >= now + (asset.starts or now) <= now + and (asset.ends or now) >= now ) ) ] @@ -81,30 +94,30 @@ def get_random(size=16): return "".join("%02x" % random.getrandbits(8) for i in range(size)) -def make_asset_json(assets, mod_data=False): +def make_asset_json(assets: Iterable[Asset], mod_data=False): return jsonify( assets=[ { - "user": asset["userdata"]["user"], - "filetype": asset["filetype"], - "thumb": asset["thumb"], + "user": asset.user, + "filetype": asset.filetype, + "thumb": asset.thumb, "url": url_for("static", filename=cached_asset_name(asset)), } | ({ "moderate_url": url_for( - "content_moderate", asset_id=asset["id"], _external=True + "content_moderate", asset_id=asset.id, _external=True ), - "moderated_by": asset["userdata"].get("moderated_by"), + "moderated_by": asset.moderated_by, } if mod_data else {}) for asset in assets ] ) -def cached_asset_name(asset): - asset_id = asset["id"] +def cached_asset_name(asset: Asset): + asset_id = asset.id filename = "asset-{}.{}".format( asset_id, - "jpg" if asset["filetype"] == "image" else "mp4", + "jpg" if asset.filetype == "image" else "mp4", ) cache_name = os.path.join(CONFIG.get('STATIC_PATH', 'static'), filename) diff --git a/requirements.txt b/requirements.txt index f0f559f..4c5ae71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,6 +18,7 @@ MarkupSafe==2.1.5 oauth2==1.9.0.post1 packaging==24.0 paho-mqtt==2.1.0 +prometheus_client==0.20.0 pyparsing==3.1.2 redis==5.0.4 requests==2.31.0 From bc1270bc93f1db68d26059cf88cfa1607c9f0986 Mon Sep 17 00:00:00 2001 From: Sam Willcocks Date: Wed, 15 May 2024 20:10:35 +0100 Subject: [PATCH 08/30] Add all known states to metrics --- frontend.py | 17 +++++++++++------ helper.py | 17 ++++++++++++----- syncer.py | 4 ++-- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/frontend.py b/frontend.py index 5b8c13a..0d0dfbe 100644 --- a/frontend.py +++ b/frontend.py @@ -26,6 +26,7 @@ from conf import CONFIG from helper import ( + State, admin_required, cached_asset_name, error, @@ -68,6 +69,10 @@ def collect(self) -> Iterable[Metric]: for a in get_assets(): counts[a.state] += 1 g = GaugeMetricFamily("submissions", "Counts of content submissions", labels=["state"]) + for state in State: + # Add any states that we know about but have 0 assets in them + if state.value not in counts.keys(): + counts[state.value] = 0 for s, c in counts.items(): g.add_metric([s], c) yield g @@ -287,7 +292,7 @@ def content_request_review(asset_id): return error("Cannot review") if g.user_is_admin: - update_asset_userdata(asset, state="confirmed") + update_asset_userdata(asset, state=State.CONFIRMED) app.logger.warn( "auto-confirming {} because it was uploaded by admin {}".format( asset["id"], g.user @@ -335,7 +340,7 @@ def content_moderate(asset_id): abort(404) state = asset["userdata"].get("state", "new") - if state == "deleted": + if state == State.DELETED: app.logger.info( f"request to moderate asset {asset_id} failed because asset was deleted by user" ) @@ -375,7 +380,7 @@ def content_moderate_result(asset_id, result): abort(404) state = asset["userdata"].get("state", "new") - if state == "deleted": + if state == State.DELETED: app.logger.info( f"request to moderate asset {asset_id} failed because asset was deleted by user" ) @@ -383,10 +388,10 @@ def content_moderate_result(asset_id, result): if result == "confirm": app.logger.info("Asset {} was confirmed".format(asset["id"])) - update_asset_userdata(asset, state="confirmed", moderated_by=g.user) + update_asset_userdata(asset, state=State.CONFIRMED, moderated_by=g.user) else: app.logger.info("Asset {} was rejected".format(asset["id"])) - update_asset_userdata(asset, state="rejected", moderated_by=g.user) + update_asset_userdata(asset, state=State.REJECTED, moderated_by=g.user) return jsonify(ok=True) @@ -432,7 +437,7 @@ def content_delete(asset_id): return error("Cannot delete") try: - update_asset_userdata(asset, state="deleted") + update_asset_userdata(asset, state=State.DELETED) except Exception as e: app.logger.error(f"content_delete({asset_id}) {repr(e)}") return error("Cannot delete") diff --git a/helper.py b/helper.py index 30d7eed..f216a75 100644 --- a/helper.py +++ b/helper.py @@ -1,3 +1,4 @@ +import enum import os import random from datetime import datetime @@ -27,11 +28,17 @@ def decorated_function(*args, **kwargs): return f(*args, **kwargs) return decorated_function +class State(enum.StrEnum): + NEW = "new" + CONFIRMED = "confirmed" + REJECTED = "rejected" + DELETED = "deleted" + class Asset(NamedTuple): id: str filetype: str thumb: str - state: str + state: State user: str starts: Optional[str] ends: Optional[str] @@ -46,7 +53,7 @@ def get_assets(): filetype=asset["filetype"], thumb=asset["thumb"], user=asset["userdata"]["user"], - state=asset["userdata"].get("state", "new"), + state=State(asset["userdata"].get("state", "new")), starts=asset["userdata"].get("starts"), ends=asset["userdata"].get("ends"), ) for asset in assets if asset["userdata"].get("user") != None @@ -55,14 +62,14 @@ def get_assets(): def get_user_assets(): return [ a for a in get_assets() - if a.user == g.user and a.state != "deleted" + if a.user == g.user and a.state != State.DELETED ] def get_assets_awaiting_moderation(): return [ asset for asset in get_assets() - if asset.state == "new" + if asset.state == State.NEW ] @@ -71,7 +78,7 @@ def get_all_live_assets(no_time_filter=False): return [ asset for asset in get_assets() - if asset.state in ("confirmed",) + if asset.state in (State.CONFIRMED,) and ( no_time_filter or ( diff --git a/syncer.py b/syncer.py index 13fa6d7..a1822c3 100644 --- a/syncer.py +++ b/syncer.py @@ -3,7 +3,7 @@ from logging import getLogger from conf import CONFIG -from helper import get_all_live_assets, user_is_admin +from helper import State, get_all_live_assets, user_is_admin from ib_hosted import ib from voc_mqtt import send_message @@ -17,7 +17,7 @@ for asset in ib.get("asset/list")["assets"]: if asset["userdata"].get("user") is not None and asset["userdata"].get( "state" - ) not in ("confirmed", "rejected", "deleted"): + ) not in (State.CONFIRMED, State.REJECTED, State.DELETED): state = asset["userdata"]["state"] if state not in asset_states: asset_states[state] = 0 From 2e29478955bc6995db2df7b3d9e197a2860af6bd Mon Sep 17 00:00:00 2001 From: Sam Willcocks Date: Wed, 15 May 2024 23:58:04 +0100 Subject: [PATCH 09/30] Add general infobeamer metrics --- frontend.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/frontend.py b/frontend.py index 0d0dfbe..43bd84c 100644 --- a/frontend.py +++ b/frontend.py @@ -77,8 +77,30 @@ def collect(self) -> Iterable[Metric]: g.add_metric([s], c) yield g +class InfobeamerCollector(Collector): + """Prometheus collector for general infobeamer metrics available from the hosted API.""" + last_got = 0 + devices = [] + def collect(self) -> Iterable[Metric]: + if (self.last_got + 10) < datetime.now().timestamp(): + self.devices = ib.get("device/list")["devices"] + self.last_got = datetime.now().timestamp() + yield GaugeMetricFamily("devices", "Infobeamer devices", len(self.devices)) + yield GaugeMetricFamily("devices_online", "Infobeamer devices online", len([d for d in self.devices if d["is_online"]])) + m = GaugeMetricFamily("device_model", "Infobeamer device models", labels=["model"]) + counts = defaultdict(int) + for d in self.devices: + if d.get("hw"): + counts[d["hw"]["model"]] += 1 + else: + counts["unknown"] += 1 + for model, count in counts.items(): + m.add_metric([model], count) + yield m + REGISTRY.register(SubmissionsCollector()) +REGISTRY.register(InfobeamerCollector()) github = GitHub(app) From 3254593feea76ffd670c1e6fbdb5c752ce086558 Mon Sep 17 00:00:00 2001 From: Sam Willcocks Date: Fri, 17 May 2024 23:55:19 +0100 Subject: [PATCH 10/30] Unbreak all the stuff I broke --- frontend.py | 21 ++++++------------ helper.py | 59 ++++++++++++++++++++++++------------------------- static/index.js | 4 ++-- 3 files changed, 38 insertions(+), 46 deletions(-) diff --git a/frontend.py b/frontend.py index 43bd84c..83b27b0 100644 --- a/frontend.py +++ b/frontend.py @@ -31,12 +31,12 @@ cached_asset_name, error, get_all_live_assets, + get_asset, get_assets, get_assets_awaiting_moderation, get_random, get_user_assets, login_disabled_for_user, - make_asset_json, user_is_admin, ) from ib_hosted import get_scoped_api_key, ib, update_asset_userdata @@ -219,7 +219,7 @@ def content_list(): if not g.user: session["redirect_after_login"] = request.url return redirect(url_for("login")) - assets = get_user_assets() + assets = [a._asdict() for a in get_user_assets()] random.shuffle(assets) return jsonify( assets=assets, @@ -228,7 +228,7 @@ def content_list(): @app.route("/content/awaiting_moderation") @admin_required def content_awaiting_moderation(): - return make_asset_json(get_assets_awaiting_moderation(), mod_data=True) + return jsonify([a.to_dict(mod_data=True) for a in get_assets_awaiting_moderation()]) @app.route("/content/upload", methods=["POST"]) @@ -354,15 +354,14 @@ def content_moderate(asset_id): abort(401) try: - asset = ib.get(f"asset/{asset_id}") + asset = get_asset(asset_id) except Exception: app.logger.info( f"request to moderate asset {asset_id} failed because asset does not exist" ) abort(404) - state = asset["userdata"].get("state", "new") - if state == State.DELETED: + if asset.state == State.DELETED: app.logger.info( f"request to moderate asset {asset_id} failed because asset was deleted by user" ) @@ -370,13 +369,7 @@ def content_moderate(asset_id): return render_template( "moderate.jinja", - asset={ - "id": asset["id"], - "user": asset["userdata"]["user"], - "filetype": asset["filetype"], - "url": url_for("static", filename=cached_asset_name(asset)), - "state": state, - }, + asset=asset.to_dict() ) @@ -472,7 +465,7 @@ def content_live(): no_time_filter = request.values.get("all") assets = get_all_live_assets(no_time_filter=no_time_filter) random.shuffle(assets) - resp = make_asset_json(assets, mod_data=g.user_is_admin) + resp = jsonify([a.to_dict(mod_data=g.user_is_admin) for a in assets]) resp.headers["Cache-Control"] = "public, max-age=30" return resp diff --git a/helper.py b/helper.py index f216a75..df1e2dd 100644 --- a/helper.py +++ b/helper.py @@ -45,19 +45,37 @@ class Asset(NamedTuple): moderate_url: Optional[str] = None moderated_by: Optional[str] = None + def to_dict(self, mod_data=False): + return { + "user": self.user, + "filetype": self.filetype, + "thumb": self.thumb, + "url": url_for("static", filename=cached_asset_name(self)), + } | ({ + "moderate_url": url_for( + "content_moderate", asset_id=self.id, _external=True + ), + "moderated_by": self.moderated_by, + } if mod_data else {}) + + +def parse_asset(asset): + return Asset( + id=asset["id"], + filetype=asset["filetype"], + thumb=asset["thumb"], + user=asset["userdata"]["user"], + state=State(asset["userdata"].get("state", "new")), + starts=asset["userdata"].get("starts"), + ends=asset["userdata"].get("ends"), + ) + +def get_asset(id): + return parse_asset(ib.get(f"asset/{id}")) + def get_assets(): assets = ib.get("asset/list")["assets"] - return [ - Asset( - id=asset["id"], - filetype=asset["filetype"], - thumb=asset["thumb"], - user=asset["userdata"]["user"], - state=State(asset["userdata"].get("state", "new")), - starts=asset["userdata"].get("starts"), - ends=asset["userdata"].get("ends"), - ) for asset in assets if asset["userdata"].get("user") != None - ] + return [ parse_asset(asset) for asset in assets if asset["userdata"].get("user") != None] def get_user_assets(): return [ @@ -101,25 +119,6 @@ def get_random(size=16): return "".join("%02x" % random.getrandbits(8) for i in range(size)) -def make_asset_json(assets: Iterable[Asset], mod_data=False): - return jsonify( - assets=[ - { - "user": asset.user, - "filetype": asset.filetype, - "thumb": asset.thumb, - "url": url_for("static", filename=cached_asset_name(asset)), - } | ({ - "moderate_url": url_for( - "content_moderate", asset_id=asset.id, _external=True - ), - "moderated_by": asset.moderated_by, - } if mod_data else {}) - for asset in assets - ] - ) - - def cached_asset_name(asset: Asset): asset_id = asset.id filename = "asset-{}.{}".format( diff --git a/static/index.js b/static/index.js index 6b4054c..9183f09 100644 --- a/static/index.js +++ b/static/index.js @@ -82,7 +82,7 @@ Vue.component('list-active', { }), async created() { const r = await Vue.http.get('content/live') - this.assets = r.data.assets + this.assets = r.data } }) @@ -106,7 +106,7 @@ Vue.component('list-unmoderated', { }), async created() { const r = await Vue.http.get('content/awaiting_moderation') - this.assets = r.data.assets + this.assets = r.data } }) From ddb3ca01a9be86e9b61f48965f39aae7faf64e3a Mon Sep 17 00:00:00 2001 From: Sam Willcocks Date: Sat, 18 May 2024 00:09:12 +0100 Subject: [PATCH 11/30] Fix moderation --- helper.py | 1 + 1 file changed, 1 insertion(+) diff --git a/helper.py b/helper.py index df1e2dd..2fcbc4a 100644 --- a/helper.py +++ b/helper.py @@ -47,6 +47,7 @@ class Asset(NamedTuple): def to_dict(self, mod_data=False): return { + "id": self.id, "user": self.user, "filetype": self.filetype, "thumb": self.thumb, From d8a08f8ef599164690f423648c0415fde927e38a Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Sat, 18 May 2024 20:23:49 +0100 Subject: [PATCH 12/30] Basic updates to python 3.11 --- .gitignore | 1 + .python-version | 1 + .vscode/launch.json | 19 +++++++++++++++++++ requirements.txt | 1 + 4 files changed, 22 insertions(+) create mode 100644 .python-version create mode 100644 .vscode/launch.json diff --git a/.gitignore b/.gitignore index cbf6ba8..95bf275 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ env/ static/asset-* settings.toml +__pycache__ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a111315 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "env": { + "SETTINGS": "./settings.toml", + "PYENV_VERSION": "3.11.9" + } + } + ] +} diff --git a/requirements.txt b/requirements.txt index 4c5ae71..e49ed29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,3 +27,4 @@ Werkzeug==3.0.3 wrapt==1.16.0 zope.event==5.0 zope.interface==6.3 +rtoml==0.10.0 From 3dd9aeef6c88686812d1a2312b35dcff0fac56a3 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Sat, 18 May 2024 20:24:07 +0100 Subject: [PATCH 13/30] Update syncer to new infobeamer data --- syncer.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/syncer.py b/syncer.py index a1822c3..a46eb2d 100644 --- a/syncer.py +++ b/syncer.py @@ -3,7 +3,7 @@ from logging import getLogger from conf import CONFIG -from helper import State, get_all_live_assets, user_is_admin +from helper import Asset, State, get_all_live_assets, user_is_admin from ib_hosted import ib from voc_mqtt import send_message @@ -40,15 +40,15 @@ ) -def asset_to_tiles(asset): - log.debug("adding {} to Page".format(asset["id"])) +def asset_to_tiles(asset: Asset): + log.debug("adding {} to Page".format(asset.id)) tiles = [] - if asset["filetype"] == "video": + if asset.filetype == "video": tiles.append( { "type": "rawvideo", - "asset": asset["id"], + "asset": asset.id, "x1": 0, "y1": 0, "x2": 1920, @@ -64,7 +64,7 @@ def asset_to_tiles(asset): tiles.append( { "type": "image", - "asset": asset["id"], + "asset": asset.id, "x1": 0, "y1": 0, "x2": 1920, @@ -72,7 +72,7 @@ def asset_to_tiles(asset): "config": {"fade_time": 0.5}, } ) - if not user_is_admin(asset["userdata"]["user"]): + if not user_is_admin(asset.user): tiles.append( { "type": "flat", @@ -96,7 +96,7 @@ def asset_to_tiles(asset): "font_size": 25, "fade_time": 0.5, "text": "Project by @{user} - visit {url} to share your own.".format( - user=asset["userdata"]["user"], + user=asset.user, url=CONFIG["DOMAIN"], ), "color": "#dddddd", @@ -121,7 +121,7 @@ def asset_to_tiles(asset): "tiles": asset_to_tiles(asset), } ) - assets_visible.add(asset["id"]) + assets_visible.add(asset.id) log.info( "There are currently {} pages visible with asset ids: {}".format( From 923dbf47e37269c4e03447ff966ffa57a14a8aeb Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Sat, 18 May 2024 20:24:27 +0100 Subject: [PATCH 14/30] Update systemd timer service to use the python syncer.py --- infobeamer-cms-runperiodic.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infobeamer-cms-runperiodic.service b/infobeamer-cms-runperiodic.service index 874c4f2..7f46a1d 100644 --- a/infobeamer-cms-runperiodic.service +++ b/infobeamer-cms-runperiodic.service @@ -8,4 +8,4 @@ Requires=infobeamer-cms.service User=infobeamer-cms Group=infobeamer-cms WorkingDirectory=/opt/infobeamer-cms -ExecStart=curl -s -H "Host: infobeamer-cms.example.org" http://127.0.0.1:8000/sync +ExecStart=/opt/infobeamer-cms/.venv/bin/python syncer.py From ac5407a2e6a1573c154de01135c00c08467145bb Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Sat, 18 May 2024 20:32:49 +0100 Subject: [PATCH 15/30] syncer needs the config too :) --- infobeamer-cms-runperiodic.service | 1 + 1 file changed, 1 insertion(+) diff --git a/infobeamer-cms-runperiodic.service b/infobeamer-cms-runperiodic.service index 7f46a1d..2bdf040 100644 --- a/infobeamer-cms-runperiodic.service +++ b/infobeamer-cms-runperiodic.service @@ -5,6 +5,7 @@ After=network.target Requires=infobeamer-cms.service [Service] +Environment=SETTINGS=/opt/infobeamer-cms/settings.toml User=infobeamer-cms Group=infobeamer-cms WorkingDirectory=/opt/infobeamer-cms From 3fbaab77fc9e0e305827a9136e44947e7f551367 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Sat, 18 May 2024 20:40:48 +0100 Subject: [PATCH 16/30] Add readme for user creation for systemd service --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index d556033..ac9729e 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,12 @@ systemctl restart nginx # adapt those settings cp settings.example.toml settings.toml + # start via systemd +# make a user that can't logic with no home dir or shell. +useradd -M infobeamer-cms +usermod -L infobeamer-cms +usermod -s /bin/false infobeamer-cms cp infobeamer-cms.service /etc/systemd/system/ cp infobeamer-cms-runperiodic.service /etc/systemd/system/ cp infobeamer-cms-runperiodic.timer /etc/systemd/system/ From cb5dfa4bdac21251f65cc97cc221735720446534 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Sat, 18 May 2024 22:11:19 +0100 Subject: [PATCH 17/30] Fix state display and show the user's selected start/end time during moderation --- frontend.py | 2 +- helper.py | 9 ++++++--- static/moderate.js | 9 ++++++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/frontend.py b/frontend.py index 83b27b0..ebb9802 100644 --- a/frontend.py +++ b/frontend.py @@ -369,7 +369,7 @@ def content_moderate(asset_id): return render_template( "moderate.jinja", - asset=asset.to_dict() + asset=asset.to_dict(mod_data=True) ) diff --git a/helper.py b/helper.py index 2fcbc4a..8accead 100644 --- a/helper.py +++ b/helper.py @@ -40,8 +40,8 @@ class Asset(NamedTuple): thumb: str state: State user: str - starts: Optional[str] - ends: Optional[str] + starts: Optional[str] = None + ends: Optional[str] = None moderate_url: Optional[str] = None moderated_by: Optional[str] = None @@ -57,6 +57,9 @@ def to_dict(self, mod_data=False): "content_moderate", asset_id=self.id, _external=True ), "moderated_by": self.moderated_by, + "state": self.state, + "starts": self.starts, + "ends": self.ends } if mod_data else {}) @@ -139,4 +142,4 @@ def cached_asset_name(asset: Asset): os.chmod(cache_name, 0o664) del r - return filename \ No newline at end of file + return filename diff --git a/static/moderate.js b/static/moderate.js index 7352a18..113530f 100644 --- a/static/moderate.js +++ b/static/moderate.js @@ -15,7 +15,9 @@ Vue.component('moderate', {