From d4fef108ec3c9024fe0525c7771f39afa87b3be7 Mon Sep 17 00:00:00 2001 From: Aaron Shrimpton Date: Fri, 6 Mar 2026 08:59:31 +0000 Subject: [PATCH 01/12] Progress with importing inductions --- hackspace_mgmt/admin/machine.py | 4 +- hackspace_mgmt/general/induction.py | 71 ++++++++++++++++++- hackspace_mgmt/general/profile.py | 2 +- hackspace_mgmt/models.py | 4 +- .../templates/machine_induction.html | 5 +- .../templates/machine_induction_import.html | 31 ++++++++ migration/25_import_induction.sql | 3 + requirements-frozen.txt | 36 ++++++++++ 8 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 hackspace_mgmt/templates/machine_induction_import.html create mode 100644 migration/25_import_induction.sql create mode 100644 requirements-frozen.txt diff --git a/hackspace_mgmt/admin/machine.py b/hackspace_mgmt/admin/machine.py index 07d1d40..28c2453 100644 --- a/hackspace_mgmt/admin/machine.py +++ b/hackspace_mgmt/admin/machine.py @@ -6,11 +6,11 @@ class MachineView(ModelView): column_searchable_list = ['name'] - column_list = ('name', 'controllers', 'requires_in_person', 'valid_for_days', 'hide_from_home', 'quizzes') + column_list = ('name', 'controllers', 'requires_in_person', 'valid_for_days', 'hide_from_home', 'import_enabled', 'import_message', 'quizzes') inline_models = (MachineController,) form_excluded_columns = ('inductions',) column_formatters = dict() def create_views(admin: Admin): - admin.add_view(MachineView(Machine, db.session, endpoint="machine_view", category="Access Control")) \ No newline at end of file + admin.add_view(MachineView(Machine, db.session, endpoint="machine_view", category="Access Control")) diff --git a/hackspace_mgmt/general/induction.py b/hackspace_mgmt/general/induction.py index dc1dd5c..2e255f4 100644 --- a/hackspace_mgmt/general/induction.py +++ b/hackspace_mgmt/general/induction.py @@ -1,8 +1,15 @@ -from flask import Blueprint, render_template, g +from flask import Blueprint, flash, redirect, render_template, url_for, request, g +from flask_wtf import FlaskForm +from wtforms import fields, widgets, ValidationError, validators +from datetime import datetime, timezone + import logging from hackspace_mgmt.models import db, Machine, Induction, LegacyMachineAuth, Member from hackspace_mgmt.general.helpers import login_required +from hackspace_mgmt.audit import create_audit_log + +from sqlalchemy.dialects.postgresql import insert bp = Blueprint("induction", __name__) @@ -39,4 +46,64 @@ def machine(machine_id): expired_quizes=expired_quizes, induction=induction, LegacyMachineAuth=LegacyMachineAuth - ) \ No newline at end of file + ) + +@bp.route("/induction//import", methods=["POST", "GET"]) +@login_required +def induction_import(machine_id): + machine = db.get_or_404(Machine, machine_id) + + member: Member = g.member + + class ImportForm(FlaskForm): + submit_label = "Import" + + secret = fields.StringField('Secret',[validators.Length(max=Machine.legacy_password.type.length)]) + + import_form = ImportForm(request.form); + + secret_error = '' + now=datetime.now(timezone.utc) + + if import_form.validate_on_submit(): + + if machine.legacy_password == import_form.secret.data: + #Add Induction + secret_error = "adding induction" + + if not machine.is_member_inducted(member) : + insert_stmt = insert(Induction).values( + member_id=member.id, + machine_id=machine.id, + inducted_on=now + ) + + db.session.execute(insert_stmt) + + create_audit_log( + "induction", + "import", + data = { + "machine": { + "id": machine.id, + "name": machine.name + }, + "inductee": member.id + }, + member=member + ) + + db.session.commit() + + flash("Induction imported") + return redirect(url_for("induction.machine", machine_id=machine_id)) + + else: + secret_error = "Secret Error, check capitalisation or spaces." + + return render_template( + "machine_induction_import.html", + secret_error=secret_error, + machine=machine, + import_form=import_form + ); diff --git a/hackspace_mgmt/general/profile.py b/hackspace_mgmt/general/profile.py index 1673505..53c4447 100644 --- a/hackspace_mgmt/general/profile.py +++ b/hackspace_mgmt/general/profile.py @@ -40,4 +40,4 @@ def index(): return redirect(url_for(".index")) - return render_template("profile.html", profile_form=profile_form, return_url=url_for("general.index")) \ No newline at end of file + return render_template("profile.html", profile_form=profile_form, return_url=url_for("general.index")) diff --git a/hackspace_mgmt/models.py b/hackspace_mgmt/models.py index f835b84..419d043 100644 --- a/hackspace_mgmt/models.py +++ b/hackspace_mgmt/models.py @@ -121,6 +121,8 @@ class Machine(db.Model): hide_from_home: Mapped[bool] = mapped_column(nullable=False, default=False) requires_in_person: Mapped[bool] = mapped_column(server_default=expression.false()) induction_valid_for_days: Mapped[int] = mapped_column(server_default="0") + import_enabled: Mapped[bool] = mapped_column(nullable=False, default=False) + import_message: Mapped[str] = mapped_column(String(200), nullable=False, default="") controllers: Mapped[List["MachineController"]] = relationship(back_populates="machine") inductions: Mapped[List["Induction"]] = relationship(back_populates="machine") @@ -245,4 +247,4 @@ class AuditLog(db.Model): member_id: Mapped[int] = mapped_column(ForeignKey("member.id")) data: Mapped[Optional[JSON]] = mapped_column(type_=JSON) - member: Mapped["Member"] = relationship() \ No newline at end of file + member: Mapped["Member"] = relationship() diff --git a/hackspace_mgmt/templates/machine_induction.html b/hackspace_mgmt/templates/machine_induction.html index 26cc403..1e30625 100644 --- a/hackspace_mgmt/templates/machine_induction.html +++ b/hackspace_mgmt/templates/machine_induction.html @@ -22,6 +22,9 @@

{{ machine.name }} induction

{% endif %} {% else %}

You are not yet inducted on this machine.

+{% if machine.import_enabled %} +

Import Induction

+{% endif %} {% endif %} @@ -73,4 +76,4 @@

{{ quiz.title }}

{% endif %} {% endfor %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/hackspace_mgmt/templates/machine_induction_import.html b/hackspace_mgmt/templates/machine_induction_import.html new file mode 100644 index 0000000..8f2d739 --- /dev/null +++ b/hackspace_mgmt/templates/machine_induction_import.html @@ -0,0 +1,31 @@ +{% import 'utils.html' as utils with context %} +{% extends "base.html" %} + +{% block title %}Hackspace home{% endblock %} +{% block breadcrumbs %} +
  • Home
  • +
  • Inductions
  • +
  • {{ machine.name }}
  • +
  • induction import
  • +{% endblock %} + +{% block content %} +

    {{ machine.name }} induction import

    + +{% if machine.import_message %} +

    {{machine.import_message}}

    +{% endif %} + +{% call utils.form_tag() %} + {{ import_form.hidden_tag() }} +
    + {{ utils.render_field(quiz_form, import_form.secret) }} + {% if secret_error %} +

    {{ secret_error }}

    + {% endif %} +
    + + {{ utils.render_form_buttons(import_form, return_url) }} +{% endcall %} + +{% endblock %} diff --git a/migration/25_import_induction.sql b/migration/25_import_induction.sql new file mode 100644 index 0000000..dcede25 --- /dev/null +++ b/migration/25_import_induction.sql @@ -0,0 +1,3 @@ +ALTER TABLE IF EXISTS public.machine ADD COLUMN import_enabled boolean NOT NULL DEFAULT false; + +ALTER TABLE IF EXISTS public.machine ADD COLUMN import_message character varying(200); diff --git a/requirements-frozen.txt b/requirements-frozen.txt new file mode 100644 index 0000000..b7829e4 --- /dev/null +++ b/requirements-frozen.txt @@ -0,0 +1,36 @@ +blinker==1.6.2 +certifi==2023.5.7 +charset-normalizer==3.2.0 +click==8.1.6 +flask==3.0.2 +Flask-Admin==1.6.1 +Flask-Assets==2.1.0 +flask-sqlalchemy==3.1.1 +flask-wtf==1.2.1 +greenlet==2.0.2 +gunicorn==21.2.0 +idna==3.4 +importlib-metadata==6.8.0 +itsdangerous==2.1.2 +Jinja2==3.1.2 +MarkupSafe==2.1.3 +multidict==6.0.4 +numpy==1.25.1 +packaging==23.1 +pandas==2.0.3 +psycopg2-binary==2.9.6 +pyScss==1.4.0 +python-dateutil==2.8.2 +pytz==2023.3 +PyYAML==6.0.1 +requests==2.31.0 +six==1.16.0 +SQLAlchemy==2.0.19 +typing-extensions==4.7.1 +tzdata==2023.3 +urllib3==2.0.4 +webassets==2.0 +werkzeug==3.0.2 +wtforms==3.1.2 +yarl==1.9.2 +zipp==3.16.2 From 0e928537490a0a2abbb683b005e821732d6d6f03 Mon Sep 17 00:00:00 2001 From: Aaron Shrimpton Date: Sat, 14 Mar 2026 20:59:18 +0000 Subject: [PATCH 02/12] Import induction and machine tags --- hackspace_mgmt/__init__.py | 8 +++- hackspace_mgmt/admin/__init__.py | 5 ++- hackspace_mgmt/admin/tag.py | 14 +++++++ hackspace_mgmt/general/induction.py | 6 ++- hackspace_mgmt/general/label.py | 2 +- hackspace_mgmt/limiter.py | 7 ++++ hackspace_mgmt/machine_api.py | 65 ++++++++++++++++++++++++++++- hackspace_mgmt/models.py | 13 ++++++ migration/25_tags.sql | 24 +++++++++++ requirements-frozen.txt | 1 + requirements.txt | 3 +- 11 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 hackspace_mgmt/admin/tag.py create mode 100644 hackspace_mgmt/limiter.py create mode 100644 migration/25_tags.sql diff --git a/hackspace_mgmt/__init__.py b/hackspace_mgmt/__init__.py index ec68931..50f44d4 100644 --- a/hackspace_mgmt/__init__.py +++ b/hackspace_mgmt/__init__.py @@ -4,6 +4,8 @@ from flask import Flask from flask_assets import Environment, Bundle +from .limiter import limiter + def create_app(test_config=None): app = Flask(__name__, instance_relative_config=True) app.config.from_mapping( @@ -12,6 +14,9 @@ def create_app(test_config=None): STORAGE_LOGIN_SECRET="dev", STORAGE_APP_URL="http://example.com" ) + + limiter.init_app(app) + if test_config is None: app.config.from_pyfile("config.py", silent=True) else: @@ -46,5 +51,4 @@ def create_app(test_config=None): app.register_blueprint(label_api.bp) - - return app \ No newline at end of file + return app diff --git a/hackspace_mgmt/admin/__init__.py b/hackspace_mgmt/admin/__init__.py index edf6954..b81ee8e 100644 --- a/hackspace_mgmt/admin/__init__.py +++ b/hackspace_mgmt/admin/__init__.py @@ -1,6 +1,6 @@ from flask_admin import Admin -from . import machine, induction, firmware_update, card, bulk_card, member, label, quiz, audit +from . import machine, induction, firmware_update, card, bulk_card, member, label, quiz, audit, tag admin = Admin(None, 'Hackspace Management Admin', template_mode='bootstrap4', endpoint="admin", url="/admin") @@ -12,4 +12,5 @@ member.create_views(admin) label.create_views(admin) quiz.create_views(admin) -audit.create_views(admin) \ No newline at end of file +audit.create_views(admin) +tag.create_views(admin) diff --git a/hackspace_mgmt/admin/tag.py b/hackspace_mgmt/admin/tag.py new file mode 100644 index 0000000..6d6ab4e --- /dev/null +++ b/hackspace_mgmt/admin/tag.py @@ -0,0 +1,14 @@ +from flask_admin import Admin +from flask_admin.contrib.sqla import ModelView +from hackspace_mgmt.models import db, Tag +from flask_admin.model.form import InlineFormAdmin + + +class TagView(ModelView): + column_searchable_list = ['title'] + column_list = ('title',) + column_formatters = dict() + + +def create_views(admin: Admin): + admin.add_view(TagView(Tag, db.session, endpoint="tag_view", category="Access Control")) diff --git a/hackspace_mgmt/general/induction.py b/hackspace_mgmt/general/induction.py index 2e255f4..37dc778 100644 --- a/hackspace_mgmt/general/induction.py +++ b/hackspace_mgmt/general/induction.py @@ -15,6 +15,8 @@ logger = logging.Logger(__name__) +from .. import limiter + @bp.route("/induction") @login_required def index(): @@ -50,6 +52,7 @@ def machine(machine_id): @bp.route("/induction//import", methods=["POST", "GET"]) @login_required +@limiter.limit("5 per minute") def induction_import(machine_id): machine = db.get_or_404(Machine, machine_id) @@ -57,8 +60,7 @@ def induction_import(machine_id): class ImportForm(FlaskForm): submit_label = "Import" - - secret = fields.StringField('Secret',[validators.Length(max=Machine.legacy_password.type.length)]) + secret = fields.PasswordField(str(machine.legacy_auth).title(),[validators.Length(max=Machine.legacy_password.type.length)]) import_form = ImportForm(request.form); diff --git a/hackspace_mgmt/general/label.py b/hackspace_mgmt/general/label.py index b628d82..2dc858e 100644 --- a/hackspace_mgmt/general/label.py +++ b/hackspace_mgmt/general/label.py @@ -52,4 +52,4 @@ def create(): "expiry": label.expiry.strftime("%d %b %Y"), "caption": label.caption } - } \ No newline at end of file + } diff --git a/hackspace_mgmt/limiter.py b/hackspace_mgmt/limiter.py new file mode 100644 index 0000000..1b1e23a --- /dev/null +++ b/hackspace_mgmt/limiter.py @@ -0,0 +1,7 @@ +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address + +limiter = Limiter( + get_remote_address, + storage_uri="memory://" +) diff --git a/hackspace_mgmt/machine_api.py b/hackspace_mgmt/machine_api.py index 90b9fd1..87a97d1 100644 --- a/hackspace_mgmt/machine_api.py +++ b/hackspace_mgmt/machine_api.py @@ -4,7 +4,7 @@ ) from hackspace_mgmt.audit import create_audit_log -from .models import db, Member, Card, Machine, MachineController, Induction +from .models import db, Member, Card, Machine, MachineController, Induction, Tag, MachineTag from sqlalchemy.exc import NoResultFound from sqlalchemy.dialects.postgresql import insert from datetime import datetime, timezone @@ -267,10 +267,71 @@ def hello(hostname): def firmware_update(): return send_file("/run/hackspace-mgmt/firmware_update.bin", as_attachment=True) + +def machine_id(tm): + return tm.machine_id + +def machine_controller_res(mc): + return { + "id" : mc.id, + "machine_id" : mc.machine_id, + "hostname" : mc.hostname + } + +@bp.route('/info/tag//status') +def info_tag_status(tag): + tag_query = db.select(Tag).where(Tag.title==tag) + tag = db.session.execute(tag_query).scalar_one() + + if not tag: + return { "error" : "Tag not found" }, 404 + + tag_machine_query = db.select(MachineTag).where(MachineTag.tag_id==tag.id) + tag_machines = db.session.scalars(tag_machine_query).all(); + + machines = list(map(machine_id, tag_machines)); + + machine_controller_query = db.select(MachineController).where(MachineController.machine_id.in_(machines)) + + machine_controllers = db.session.scalars(machine_controller_query).all(); + + mcres = list(map(machine_controller_res, machine_controllers)); + + return { + "ok" : True, + "machine_count" : len(machines), + "machine_controller_count" : len(mcres), + "machine_controllers" : mcres + } + +@bp.route('/info/machine/all') +def info_machine_all(): + machine_query = db.select(Machine) + machines = []; + + for machine in db.session.scalars(machine_query).all(): + machine_controllers_query = db.select(MachineController).where(MachineController.machine_id==machine.id) + machine_controllers = db.session.scalars(machine_controllers_query).all() + mcres = list(map(machine_controller_res, machine_controllers)); + + machines.append({ + "id" : machine.id, + "name" : machine.name, + "machine_controller_count" : len(mcres), + "machine_controllers" : mcres + }) + + return { + "ok" : True, + "machine_count" : len(machines), + "machines" : machines + } + @bp.errorhandler(403) def not_authorized_error(e): return {"unlocked": False}, 403 @bp.errorhandler(404) def not_found_error(e): - return {"unlocked": False}, 404 \ No newline at end of file + return {"unlocked": False}, 404 + diff --git a/hackspace_mgmt/models.py b/hackspace_mgmt/models.py index 419d043..7d39358 100644 --- a/hackspace_mgmt/models.py +++ b/hackspace_mgmt/models.py @@ -127,6 +127,7 @@ class Machine(db.Model): controllers: Mapped[List["MachineController"]] = relationship(back_populates="machine") inductions: Mapped[List["Induction"]] = relationship(back_populates="machine") quizzes: Mapped[List["Quiz"]] = relationship(secondary="machine_quiz") + tags: Mapped[List['Tag']] = relationship(secondary="machine_tag") def is_member_inducted(self, member: Member, check_can_induct=False): member_quizzes = set(completion.quiz for completion in member.quiz_completions if not completion.has_expired()) @@ -248,3 +249,15 @@ class AuditLog(db.Model): data: Mapped[Optional[JSON]] = mapped_column(type_=JSON) member: Mapped["Member"] = relationship() + + +class Tag(db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + + def __str__(self): + return self.title + +class MachineTag(db.Model): + machine_id: Mapped[int] = mapped_column(ForeignKey("machine.id"), primary_key=True) + tag_id: Mapped[int] = mapped_column(ForeignKey("tag.id"), primary_key=True) diff --git a/migration/25_tags.sql b/migration/25_tags.sql new file mode 100644 index 0000000..7b683a3 --- /dev/null +++ b/migration/25_tags.sql @@ -0,0 +1,24 @@ +CREATE TABLE public.tag +( + id integer NOT NULL GENERATED ALWAYS AS IDENTITY, + title character varying(128) NOT NULL +); + +ALTER TABLE public.tag ADD CONSTRAINT tag_pkey PRIMARY KEY (id); + +CREATE TABLE public.machine_tag +( + machine_id integer NOT NULL, + tag_id integer NOT NULL, + PRIMARY KEY (machine_id, tag_id), + FOREIGN KEY (machine_id) + REFERENCES public.machine (id) MATCH SIMPLE + ON UPDATE CASCADE + ON DELETE CASCADE + NOT VALID, + FOREIGN KEY (tag_id) + REFERENCES public.tag (id) MATCH SIMPLE + ON UPDATE CASCADE + ON DELETE CASCADE + NOT VALID +); diff --git a/requirements-frozen.txt b/requirements-frozen.txt index b7829e4..bd91c70 100644 --- a/requirements-frozen.txt +++ b/requirements-frozen.txt @@ -34,3 +34,4 @@ werkzeug==3.0.2 wtforms==3.1.2 yarl==1.9.2 zipp==3.16.2 +flask-limiter==3.11.0 diff --git a/requirements.txt b/requirements.txt index 7c62721..fcb0766 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,5 @@ pyscss wtforms~=3.1 flask-wtf PyYaml -pyjwt \ No newline at end of file +pyjwt +flask-limiter==3.11.0 From 449332c7aafa16e0f1d5e2c394338ede689ee5d8 Mon Sep 17 00:00:00 2001 From: Aaron Shrimpton Date: Sun, 15 Mar 2026 19:37:48 +0000 Subject: [PATCH 03/12] Listen and sync --- hackspace_mgmt/listen_and_sync.py | 61 +++++++++++++++++++++++++++++++ migration/26_member_updated.sql | 36 ++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 hackspace_mgmt/listen_and_sync.py create mode 100644 migration/26_member_updated.sql diff --git a/hackspace_mgmt/listen_and_sync.py b/hackspace_mgmt/listen_and_sync.py new file mode 100644 index 0000000..1a1a6e3 --- /dev/null +++ b/hackspace_mgmt/listen_and_sync.py @@ -0,0 +1,61 @@ +import select +import datetime + +import psycopg2 +import psycopg2.extensions + +import os +import json + +from sqlalchemy import create_engine, text +from urllib.request import urlopen, Request +from urllib.error import HTTPError, URLError + +SQLALCHEMY_DATABASE_URI=os.environ['SQLALCHEMY_DATABASE_URI'] +BHS_SYNC_URL=os.environ['BHS_SYNC_URL'] +BHS_SYNC_TOKEN=os.environ['BHS_SYNC_TOKEN'] + + +engine = create_engine(SQLALCHEMY_DATABASE_URI, isolation_level="AUTOCOMMIT") + +conn = engine.connect() +conn.execute(text("LISTEN member_updated;").execution_options(autocommit=True)) +print("Waiting for notifications on channels 'member_updated' with SQL Alchemy") +while 1: + if select.select([conn.connection],[],[],30) == ([],[],[]): + print("No Changes"); + else: + conn.connection.poll() + while conn.connection.notifies: + notify = conn.connection.notifies.pop() + + row = json.loads(notify.payload) + body = { + "email" : row['email'], + "name" : row['preferred_name'] or row['first_name'] + ' ' + row['last_name'], + "updated" : row['updated'] + } + body_json = json.dumps(body).encode("utf-8") + + request = Request( + BHS_SYNC_URL, + headers = { + "Content-Type": "application/json", + "Authorization": "Bearer " + BHS_SYNC_TOKEN + }, + data = body_json, + method = "PUT" + ) + + try: + urlopen(request, timeout=5) + + except HTTPError as error: + print(error.status, error.reason) + + except URLError as error: + print(error.status, error.reason) + + except TimeoutError: + print("Request timeout") + diff --git a/migration/26_member_updated.sql b/migration/26_member_updated.sql new file mode 100644 index 0000000..27aa091 --- /dev/null +++ b/migration/26_member_updated.sql @@ -0,0 +1,36 @@ +ALTER TABLE public.member ADD COLUMN updated timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL; + +CREATE OR REPLACE FUNCTION update_updated_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated = now(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_member_updated BEFORE UPDATE + ON public.member FOR EACH ROW EXECUTE PROCEDURE + update_updated_column(); + + + +CREATE OR REPLACE FUNCTION notify_member_update() +RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify( + CAST('member_updated' AS text), + row_to_json(NEW)::text + ); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER notify_member_inserted + AFTER INSERT ON public.member + FOR EACH ROW + EXECUTE PROCEDURE notify_member_update(); + +CREATE TRIGGER notify_member_updated + AFTER UPDATE ON public.member + FOR EACH ROW + EXECUTE PROCEDURE notify_member_update(); From 0aaf6e34a557748a3d8cf1addb284624a0c548d4 Mon Sep 17 00:00:00 2001 From: Aaron Shrimpton Date: Sat, 21 Mar 2026 12:34:33 +0000 Subject: [PATCH 04/12] Scripts for container operations --- Dockerfile | 11 +++++++++++ container-build.sh | 6 ++++++ container-login.sh | 5 +++++ container-push.sh | 8 ++++++++ requirements-frozen.txt | 1 + 5 files changed, 31 insertions(+) create mode 100644 Dockerfile create mode 100755 container-build.sh create mode 100755 container-login.sh create mode 100755 container-push.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1db4017 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM docker.io/python:3.11.8 + +RUN mkdir /website + +WORKDIR /app +COPY . /app +RUN pip install --no-cache-dir -r requirements-frozen.txt +RUN pip install gunicorn +EXPOSE 8080 +ENV PYTHONUNBUFFERED=TRUE +CMD ["gunicorn", "--enable-stdio-inheritance", "-w", "2", "-b", "unix:/hackspace-mgmt/hackspace_mgmt.sock", "hackspace_mgmt:create_app()"] diff --git a/container-build.sh b/container-build.sh new file mode 100755 index 0000000..2ea28ff --- /dev/null +++ b/container-build.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +CONTAINER_CMD=$(which podman || which docker) + +$CONTAINER_CMD build -t localhost/hackspace-mgmt:latest -f Dockerfile + diff --git a/container-login.sh b/container-login.sh new file mode 100755 index 0000000..e424a71 --- /dev/null +++ b/container-login.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +CONTAINER_CMD=$(which podman || which docker) + +ssh -T registry.bristolhackspace.org 'hs-registry-token hackspace-mgmt' | $CONTAINER_CMD login --username oauth2 --password-stdin registry.bristolhackspace.org diff --git a/container-push.sh b/container-push.sh new file mode 100755 index 0000000..1e9ad5c --- /dev/null +++ b/container-push.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +CONTAINER_CMD=$(which podman || which docker) + +$CONTAINER_CMD tag localhost/hackspace-mgmt:latest registry.bristolhackspace.org/hackspace-mgmt:latest + +$CONTAINER_CMD push registry.bristolhackspace.org/hackspace-mgmt:latest + diff --git a/requirements-frozen.txt b/requirements-frozen.txt index bd91c70..4afe9bb 100644 --- a/requirements-frozen.txt +++ b/requirements-frozen.txt @@ -35,3 +35,4 @@ wtforms==3.1.2 yarl==1.9.2 zipp==3.16.2 flask-limiter==3.11.0 +jwt==1.4.0 From bc0151c67d2a79e52722eef92f554ffa83112588 Mon Sep 17 00:00:00 2001 From: Aaron Shrimpton Date: Sun, 29 Mar 2026 22:13:17 +0100 Subject: [PATCH 05/12] Updates to the sync API --- hackspace_mgmt/listen_and_sync.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hackspace_mgmt/listen_and_sync.py b/hackspace_mgmt/listen_and_sync.py index 1a1a6e3..227c97e 100644 --- a/hackspace_mgmt/listen_and_sync.py +++ b/hackspace_mgmt/listen_and_sync.py @@ -32,13 +32,14 @@ row = json.loads(notify.payload) body = { "email" : row['email'], - "name" : row['preferred_name'] or row['first_name'] + ' ' + row['last_name'], + "display_name" : row['preferred_name'] or row['first_name'] + ' ' + row['last_name'], "updated" : row['updated'] } body_json = json.dumps(body).encode("utf-8") + print(body_json) request = Request( - BHS_SYNC_URL, + BHS_SYNC_URL + str(row['id']), headers = { "Content-Type": "application/json", "Authorization": "Bearer " + BHS_SYNC_TOKEN From 9cc12aec369e312006a9381f44768bae24681991 Mon Sep 17 00:00:00 2001 From: Aaron Shrimpton Date: Wed, 1 Apr 2026 00:37:19 +0100 Subject: [PATCH 06/12] Diff from memberpi --- hackspace_mgmt/general/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hackspace_mgmt/general/__init__.py b/hackspace_mgmt/general/__init__.py index b34a595..75ef01e 100644 --- a/hackspace_mgmt/general/__init__.py +++ b/hackspace_mgmt/general/__init__.py @@ -212,7 +212,7 @@ def enroll_personal(): @login_required def storage_login(): login_secret = current_app.config["STORAGE_LOGIN_SECRET"] - name = g.member.display_name + name = str(g.member) sub = f"member_{g.member.id}" email = g.member.email @@ -238,4 +238,4 @@ def init_app(app): app.register_blueprint(quiz.bp) app.register_blueprint(label.bp) app.register_blueprint(profile.bp) - app.register_blueprint(induction.bp) \ No newline at end of file + app.register_blueprint(induction.bp) From eddf78d6f3d71a54e0fb54bc72fea813b28c3b45 Mon Sep 17 00:00:00 2001 From: Aaron Shrimpton Date: Thu, 2 Apr 2026 19:50:11 +0100 Subject: [PATCH 07/12] High water mark sync --- .gitignore | 6 +- hackspace_mgmt/high_water_mark_and_sync.py | 83 ++++++++++++++++++++++ hackspace_mgmt/listen_and_sync.py | 3 +- hackspace_mgmt/models.py | 5 +- 4 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 hackspace_mgmt/high_water_mark_and_sync.py diff --git a/.gitignore b/.gitignore index 7260d15..33ddf68 100644 --- a/.gitignore +++ b/.gitignore @@ -129,4 +129,8 @@ dmypy.json .pyre/ -*.csv \ No newline at end of file +*.csv +*.swo +*.swp +aaron-* +*-hwm.json diff --git a/hackspace_mgmt/high_water_mark_and_sync.py b/hackspace_mgmt/high_water_mark_and_sync.py new file mode 100644 index 0000000..0d5a36b --- /dev/null +++ b/hackspace_mgmt/high_water_mark_and_sync.py @@ -0,0 +1,83 @@ +import os +import json + +from sqlalchemy import create_engine, text, select, DateTime +from sqlalchemy.orm import Session +from urllib.request import urlopen, Request +from urllib.error import HTTPError, URLError +from models import Member +from datetime import date, datetime, timedelta, timezone +from time import sleep + +SQLALCHEMY_DATABASE_URI=os.environ['SQLALCHEMY_DATABASE_URI'] +BHS_SYNC_URL=os.environ['BHS_SYNC_URL'] +BHS_SYNC_TOKEN=os.environ['BHS_SYNC_TOKEN'] + +engine = create_engine(SQLALCHEMY_DATABASE_URI, isolation_level="AUTOCOMMIT") +conn = engine.connect() + +with open("member-hwm.json") as hwm_data: + hwm = json.loads(hwm_data.read()) + hwm_data.close() + +print(hwm) + +with Session(engine) as session: + latest = datetime.fromisoformat(hwm['latest']) + latest_next = latest; + stmt = select(Member).where(Member.updated > latest) + for user in session.scalars(stmt): + + if not hwm.get('initialised') and user.end_date: + continue + + body = { + "email" : user.email, + "display_name" : user.preferred_name or user.first_name + ' ' + user.last_name, + "updated" : user.updated.isoformat(), + "end_date" : str(user.end_date) + } + + body_json = json.dumps(body).encode("utf-8") + + request = Request( + BHS_SYNC_URL + str(user.id), + headers = { + "Content-Type": "application/json", + "Authorization": "Bearer " + BHS_SYNC_TOKEN + }, + data = body_json, + method = "PUT" + ) + + try: + urlopen(request, timeout=5) + + except HTTPError as error: + print(error.status, error.reason) + break; + + except URLError as error: + print(error, error.reason) + break; + + except TimeoutError: + print("Request timeout") + break; + + latest_next = user.updated + + sleep(0.1) + + hwm_next = { + "initialised" : True, + "now" : datetime.now().isoformat(), + "latest" : latest_next.isoformat() + } + +if not hwm_next : + exit(1) + +with open("member-hwm.json", "w") as f: + json.dump(hwm_next, f) + diff --git a/hackspace_mgmt/listen_and_sync.py b/hackspace_mgmt/listen_and_sync.py index 227c97e..7d63d33 100644 --- a/hackspace_mgmt/listen_and_sync.py +++ b/hackspace_mgmt/listen_and_sync.py @@ -33,7 +33,8 @@ body = { "email" : row['email'], "display_name" : row['preferred_name'] or row['first_name'] + ' ' + row['last_name'], - "updated" : row['updated'] + "updated" : row['updated'], + "end_date" : row['end_date'] } body_json = json.dumps(body).encode("utf-8") print(body_json) diff --git a/hackspace_mgmt/models.py b/hackspace_mgmt/models.py index 7d39358..3e276ab 100644 --- a/hackspace_mgmt/models.py +++ b/hackspace_mgmt/models.py @@ -1,5 +1,5 @@ import enum -from sqlalchemy import JSON, String, ForeignKey, Enum, UniqueConstraint, types, BigInteger +from sqlalchemy import JSON, String, ForeignKey, Enum, UniqueConstraint, types, BigInteger, DateTime from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.sql import expression @@ -83,6 +83,7 @@ class Member(db.Model): labels: Mapped[List["Label"]] = relationship(back_populates="member") quiz_completions: Mapped[List["QuizCompletion"]] = relationship(back_populates="member") + updated: Mapped[datetime] = mapped_column(DateTime) @hybrid_property def display_name(self): @@ -242,7 +243,7 @@ class MachineQuiz(db.Model): class AuditLog(db.Model): id: Mapped[int] = mapped_column(BigInteger, primary_key=True) - logged_at: Mapped[datetime] = mapped_column(UTCDateTime) + logged_at: Mapped[datetime] = mapped_column(DateTime) category: Mapped[str] = mapped_column(String(32)) event: Mapped[str] = mapped_column(String(32)) member_id: Mapped[int] = mapped_column(ForeignKey("member.id")) From 9abc32e38f245d9745202bcd68094830351e1ef1 Mon Sep 17 00:00:00 2001 From: Aaron Shrimpton Date: Sat, 4 Apr 2026 14:59:37 +0100 Subject: [PATCH 08/12] Prefix for ENV and join date in sync --- Dockerfile | 2 +- hackspace_mgmt/__init__.py | 2 ++ hackspace_mgmt/high_water_mark_and_sync.py | 1 + hackspace_mgmt/listen_and_sync.py | 3 ++- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1db4017..b0b0569 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,4 +8,4 @@ RUN pip install --no-cache-dir -r requirements-frozen.txt RUN pip install gunicorn EXPOSE 8080 ENV PYTHONUNBUFFERED=TRUE -CMD ["gunicorn", "--enable-stdio-inheritance", "-w", "2", "-b", "unix:/hackspace-mgmt/hackspace_mgmt.sock", "hackspace_mgmt:create_app()"] +CMD ["gunicorn", "--enable-stdio-inheritance", "-w", "2", "-b", "unix:/website/hackspace_mgmt.sock", "hackspace_mgmt:create_app()"] diff --git a/hackspace_mgmt/__init__.py b/hackspace_mgmt/__init__.py index 50f44d4..08dd966 100644 --- a/hackspace_mgmt/__init__.py +++ b/hackspace_mgmt/__init__.py @@ -15,6 +15,8 @@ def create_app(test_config=None): STORAGE_APP_URL="http://example.com" ) + app.config.from_prefixed_env('MGMT') + limiter.init_app(app) if test_config is None: diff --git a/hackspace_mgmt/high_water_mark_and_sync.py b/hackspace_mgmt/high_water_mark_and_sync.py index 0d5a36b..84f015f 100644 --- a/hackspace_mgmt/high_water_mark_and_sync.py +++ b/hackspace_mgmt/high_water_mark_and_sync.py @@ -35,6 +35,7 @@ "email" : user.email, "display_name" : user.preferred_name or user.first_name + ' ' + user.last_name, "updated" : user.updated.isoformat(), + "join_date" : str(user.join_date), "end_date" : str(user.end_date) } diff --git a/hackspace_mgmt/listen_and_sync.py b/hackspace_mgmt/listen_and_sync.py index 7d63d33..9f2ce72 100644 --- a/hackspace_mgmt/listen_and_sync.py +++ b/hackspace_mgmt/listen_and_sync.py @@ -34,7 +34,8 @@ "email" : row['email'], "display_name" : row['preferred_name'] or row['first_name'] + ' ' + row['last_name'], "updated" : row['updated'], - "end_date" : row['end_date'] + "end_date" : row['end_date'], + "join_date" : row['join_date'] } body_json = json.dumps(body).encode("utf-8") print(body_json) From 58f97222c76d3214bce4cee980b8aec39590ba1e Mon Sep 17 00:00:00 2001 From: Aaron Shrimpton Date: Sun, 5 Apr 2026 19:42:33 +0100 Subject: [PATCH 09/12] Correcting JWT package --- requirements-frozen.txt | 2 +- requirements.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements-frozen.txt b/requirements-frozen.txt index 4afe9bb..13a3029 100644 --- a/requirements-frozen.txt +++ b/requirements-frozen.txt @@ -19,6 +19,7 @@ numpy==1.25.1 packaging==23.1 pandas==2.0.3 psycopg2-binary==2.9.6 +PyJWT==2.10.1 pyScss==1.4.0 python-dateutil==2.8.2 pytz==2023.3 @@ -35,4 +36,3 @@ wtforms==3.1.2 yarl==1.9.2 zipp==3.16.2 flask-limiter==3.11.0 -jwt==1.4.0 diff --git a/requirements.txt b/requirements.txt index fcb0766..cb60e00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ flask-wtf PyYaml pyjwt flask-limiter==3.11.0 +PyJWT==2.10.1 From 62ea84aefdef963a1a8c9d7b72ccfe29c4208125 Mon Sep 17 00:00:00 2001 From: Aaron Shrimpton Date: Tue, 7 Apr 2026 22:27:59 +0100 Subject: [PATCH 10/12] Fixing mononym handling --- hackspace_mgmt/high_water_mark_and_sync.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/hackspace_mgmt/high_water_mark_and_sync.py b/hackspace_mgmt/high_water_mark_and_sync.py index 84f015f..b64c901 100644 --- a/hackspace_mgmt/high_water_mark_and_sync.py +++ b/hackspace_mgmt/high_water_mark_and_sync.py @@ -31,9 +31,17 @@ if not hwm.get('initialised') and user.end_date: continue + if user.preferred_name : + display_name = user.preferred_name + else : + display_name = user.first_name + + if user.last_name : + display_name = display_name + ' ' + user.last_name + body = { "email" : user.email, - "display_name" : user.preferred_name or user.first_name + ' ' + user.last_name, + "display_name" : display_name, "updated" : user.updated.isoformat(), "join_date" : str(user.join_date), "end_date" : str(user.end_date) From 1a5d5cbbd89d5ea19c24ad0aa36f0d94954d82da Mon Sep 17 00:00:00 2001 From: Aaron Shrimpton Date: Tue, 7 Apr 2026 22:54:47 +0100 Subject: [PATCH 11/12] Correcting requirements --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cb60e00..24e5f04 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,5 @@ pyscss wtforms~=3.1 flask-wtf PyYaml -pyjwt flask-limiter==3.11.0 PyJWT==2.10.1 From 961e867a1166e954fc129dc82dec17b6a5cb8de0 Mon Sep 17 00:00:00 2001 From: Aaron Shrimpton Date: Sun, 12 Apr 2026 18:28:39 +0100 Subject: [PATCH 12/12] Postgres pool recyling options --- Dockerfile | 3 +-- hackspace_mgmt/__init__.py | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index b0b0569..0a06438 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,5 @@ WORKDIR /app COPY . /app RUN pip install --no-cache-dir -r requirements-frozen.txt RUN pip install gunicorn -EXPOSE 8080 ENV PYTHONUNBUFFERED=TRUE -CMD ["gunicorn", "--enable-stdio-inheritance", "-w", "2", "-b", "unix:/website/hackspace_mgmt.sock", "hackspace_mgmt:create_app()"] +CMD ["gunicorn", "--enable-stdio-inheritance", "-w", "2", "-b", "unix:/website/hackspace_mgmt.sock", "--error-logfile", "-", "--access-logfile", "-", "--capture-output", "hackspace_mgmt:create_app()"] diff --git a/hackspace_mgmt/__init__.py b/hackspace_mgmt/__init__.py index 08dd966..f184760 100644 --- a/hackspace_mgmt/__init__.py +++ b/hackspace_mgmt/__init__.py @@ -11,6 +11,11 @@ def create_app(test_config=None): app.config.from_mapping( SECRET_KEY="dev", SQLALCHEMY_DATABASE_URI="postgresql+psycopg2://postgres:postgres@localhost:5432/hackspace", + SQLALCHEMY_ENGINE_OPTIONS={ + 'pool_size': 5, + 'pool_recycle' : 60, + 'pool_pre_ping' : True + }, STORAGE_LOGIN_SECRET="dev", STORAGE_APP_URL="http://example.com" )