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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0a06438 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +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 +ENV PYTHONUNBUFFERED=TRUE +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/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/hackspace_mgmt/__init__.py b/hackspace_mgmt/__init__.py index ec68931..f184760 100644 --- a/hackspace_mgmt/__init__.py +++ b/hackspace_mgmt/__init__.py @@ -4,14 +4,26 @@ 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( 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" ) + + app.config.from_prefixed_env('MGMT') + + limiter.init_app(app) + if test_config is None: app.config.from_pyfile("config.py", silent=True) else: @@ -46,5 +58,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/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/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/__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) diff --git a/hackspace_mgmt/general/induction.py b/hackspace_mgmt/general/induction.py index dc1dd5c..37dc778 100644 --- a/hackspace_mgmt/general/induction.py +++ b/hackspace_mgmt/general/induction.py @@ -1,13 +1,22 @@ -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__) logger = logging.Logger(__name__) +from .. import limiter + @bp.route("/induction") @login_required def index(): @@ -39,4 +48,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 +@limiter.limit("5 per minute") +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.PasswordField(str(machine.legacy_auth).title(),[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/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/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/high_water_mark_and_sync.py b/hackspace_mgmt/high_water_mark_and_sync.py new file mode 100644 index 0000000..b64c901 --- /dev/null +++ b/hackspace_mgmt/high_water_mark_and_sync.py @@ -0,0 +1,92 @@ +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 + + 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" : display_name, + "updated" : user.updated.isoformat(), + "join_date" : str(user.join_date), + "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/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/listen_and_sync.py b/hackspace_mgmt/listen_and_sync.py new file mode 100644 index 0000000..9f2ce72 --- /dev/null +++ b/hackspace_mgmt/listen_and_sync.py @@ -0,0 +1,64 @@ +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'], + "display_name" : row['preferred_name'] or row['first_name'] + ' ' + row['last_name'], + "updated" : row['updated'], + "end_date" : row['end_date'], + "join_date" : row['join_date'] + } + body_json = json.dumps(body).encode("utf-8") + print(body_json) + + request = Request( + BHS_SYNC_URL + str(row['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) + + except URLError as error: + print(error.status, error.reason) + + except TimeoutError: + print("Request timeout") + 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 f835b84..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): @@ -121,10 +122,13 @@ 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") 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()) @@ -239,10 +243,22 @@ 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")) data: Mapped[Optional[JSON]] = mapped_column(type_=JSON) - member: Mapped["Member"] = relationship() \ No newline at end of file + 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/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/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/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(); diff --git a/requirements-frozen.txt b/requirements-frozen.txt new file mode 100644 index 0000000..13a3029 --- /dev/null +++ b/requirements-frozen.txt @@ -0,0 +1,38 @@ +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 +PyJWT==2.10.1 +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 +flask-limiter==3.11.0 diff --git a/requirements.txt b/requirements.txt index 7c62721..24e5f04 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 +flask-limiter==3.11.0 +PyJWT==2.10.1