diff --git a/.gitignore b/.gitignore index 75e0152..54c9566 100644 --- a/.gitignore +++ b/.gitignore @@ -175,4 +175,5 @@ cython_debug/ .ruff_cache/ # PyPI configuration file -.pypirc \ No newline at end of file +.pypirc +.env.local \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 752e79e..e8cb567 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,12 @@ dependencies = [ "aioboto3>=15.0.0", "python-magic>=0.4.27", "psycopg2-binary>=2.9.11", + "flask[async]>=3.1.2", + "flask-admin>=2.0.2", + "flask-login>=0.6.3", + "flask-sqlalchemy>=3.1.1", + "flask-wtf>=1.2.2", + "werkzeug>=3.1.5", ] [project.optional-dependencies] diff --git a/server/admin/__init__.py b/server/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/admin/__main__.py b/server/admin/__main__.py new file mode 100644 index 0000000..e614e1e --- /dev/null +++ b/server/admin/__main__.py @@ -0,0 +1,124 @@ + +import flask_login +from flask import Response, redirect, render_template, request, url_for +from flask_admin import Admin, AdminIndexView, expose +from flask_admin.theme import Bootstrap4Theme +from flask_wtf import FlaskForm +from wtforms import fields, validators + +from server.admin.auth import POOL, User +from server.admin.config import app, db_sync +from server.admin.views.attachments import AttachmentView +from server.config import get_app_config +from server.infra.db import AsyncDB +from server.modules.attachments import AtachmentService, DBAttachmentRepository, S3StorageDriver +from server.modules.attachments.repository.models import Attachment + +login_manager = flask_login.LoginManager() +login_manager.init_app(app) + + +cnf = get_app_config() +db_async = AsyncDB(cnf) +attach_service = AtachmentService(S3StorageDriver(cnf=cnf), DBAttachmentRepository(db_async)) + + +@app.route("/") +def index(): + return render_template("index.html") + + +@app.route("/admin/attachment/", methods=["GET"]) +async def get_file(raw_path: str): + attach_path = attach_service.path_from_url(raw_path) + async with attach_service._repository.db().session_slave() as session: + attach = await attach_service.get_info_bypath(session=session, path=[attach_path]) + if not attach: + raise FileNotFoundError + body = await attach_service.get_data(attach_path) + + cache_ctrl = "public, max-age=864000" # 10 дней + last_mod = attach[0].created_at.strftime("%a, %d %b %Y %H:%M:%S GMT") + + return Response( + body, + mimetype=attach[0].content_type, + headers={ + "Cache-Control": cache_ctrl, + "Last-Modified": last_mod, + }, + ) + + +@login_manager.user_loader +def load_user(alternative_id): + for user in POOL: + if user.alternative_id != alternative_id: + continue + return user + + +class LoginForm(FlaskForm): + username = fields.StringField(validators=[validators.InputRequired()]) + password = fields.PasswordField(validators=[validators.InputRequired()]) + + user: User | None = None # To store the authenticated user for later use + + def validate_username(self, field): + self.user = User.get(field.data, "username") + if self.user is None: + raise validators.ValidationError("Invalid username") + + def validate_password(self, field): + if self.user is None: + # Skip password check if username validation already failed + return + if not self.user.check_password(field.data): + raise validators.ValidationError("Invalid password") + + +# Create customized index view class that handles login & registration +class ExtAdminIndexView(AdminIndexView): + @expose("/") + def index(self): + if not flask_login.current_user.is_authenticated: + return redirect(url_for(".login_view")) + return super().index() + + @expose("/login/", methods=("GET", "POST")) + def login_view(self): + if flask_login.current_user.is_authenticated: + return redirect(url_for(".index")) + + # handle user login + form = LoginForm(request.form) + if form.validate_on_submit(): + user = form.user + flask_login.login_user(user) + # rredirect to next + if "next" in request.args: + next_url = request.args.get("next") + if next_url: + return redirect(next_url) + return redirect(url_for(".index")) + + self._template_args["form"] = form + return super().index() + + @expose("/logout/") + def logout_view(self): + flask_login.logout_user() + return redirect(url_for(".index")) + + +if __name__ == "__main__": + admin = Admin( + app, + name="Beerds", + index_view=ExtAdminIndexView(), + theme=Bootstrap4Theme(base_template="my_master.html", fluid=True), + ) + + admin.add_view(AttachmentView(Attachment, db_sync.session)) + + app.run(debug=False) diff --git a/server/admin/auth.py b/server/admin/auth.py new file mode 100644 index 0000000..c77d3ac --- /dev/null +++ b/server/admin/auth.py @@ -0,0 +1,47 @@ +import typing as t +from uuid import uuid4 + +import flask_login + +from server.config import get_app_config + +cnf = get_app_config() +POOL: list["User"] = [] + + +class User(flask_login.UserMixin): + def __init__(self): + super().__init__() + self.user_name = cnf.admin_login + self._password = cnf.admin_pass + self.alternative_id = None + self.id = self.user_name + + @property + def password(self): + return self._password + + def check_password(self, password: str | None) -> bool: + if password is not None: + if self._password == password: + self.alternative_id = str(uuid4()) + POOL.append(self) + return True + return False + + def get_id(self): + """ + Override flask_login.UserMixin.get_id to return alternative_id for security. + """ + return self.alternative_id + + def __repr__(self): + return self.user_name + + @staticmethod + def get(data, field) -> t.Optional["User"]: + if field != "username": + return None + if data != cnf.admin_login: + return None + return User() diff --git a/server/admin/config.py b/server/admin/config.py new file mode 100644 index 0000000..000e357 --- /dev/null +++ b/server/admin/config.py @@ -0,0 +1,19 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy + +from server import config + +cnf = config.get_app_config() + +app = Flask(__name__) +app.config["SQLALCHEMY_DATABASE_URI"] = ( + cnf.db_uri.replace("+asyncpg", "") + "?options=-c%20search_path=" + str(cnf.db_search_path) +) +app.config["SQLALCHEMY_ENGINE_OPTIONS"] = { + "pool_recycle": 600, + "pool_pre_ping": True, + "pool_size": 5, +} +app.config["SECRET_KEY"] = cnf.admin_secret_key +app.config["SQLALCHEMY_ECHO"] = True +db_sync = SQLAlchemy(app) diff --git a/server/admin/templates/admin/index.html b/server/admin/templates/admin/index.html new file mode 100644 index 0000000..6b20581 --- /dev/null +++ b/server/admin/templates/admin/index.html @@ -0,0 +1,40 @@ + +{% extends 'admin/master.html' %} +{% block body %} +{{ super() }} +
+ +
+ {% if current_user.is_authenticated %} +

Flask-Admin example

+

+ Authentication +

+

+ This example shows how you can use Flask-Login for authentication. It is only intended as a basic demonstration. +

+ {% else %} +
+ {{ form.hidden_tag() if form.hidden_tag }} + {% for f in form if f.type != 'CSRFTokenField' %} +
+ {{ f.label }}
+ {{ f }} + {% if f.errors %} +
    + {% for e in f.errors %} +
  • {{ e }}
  • + {% endfor %} +
+ {% endif %} +
+ {% endfor %} + +
+ {{ link | safe }} + {% endif %} +
+ + Back +
+{% endblock body %} \ No newline at end of file diff --git a/server/admin/templates/index.html b/server/admin/templates/index.html new file mode 100644 index 0000000..d9069fa --- /dev/null +++ b/server/admin/templates/index.html @@ -0,0 +1,7 @@ + + +
+ Go to admin! +
+ + \ No newline at end of file diff --git a/server/admin/templates/my_master.html b/server/admin/templates/my_master.html new file mode 100644 index 0000000..282dfaf --- /dev/null +++ b/server/admin/templates/my_master.html @@ -0,0 +1,15 @@ +{% extends 'admin/base.html' %} + +{% block access_control %} +{% if current_user.is_authenticated %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/server/admin/views/attachments.py b/server/admin/views/attachments.py new file mode 100644 index 0000000..a6a1471 --- /dev/null +++ b/server/admin/views/attachments.py @@ -0,0 +1,32 @@ +import flask_login +from flask import flash, redirect, request, url_for +from flask_admin.contrib.sqla import ModelView +from markupsafe import Markup + + +# Create customized model view class +class AttachmentView(ModelView): + can_edit = False + can_delete = False + can_create = False + column_default_sort = ("created_at", True) + + def is_accessible(self): + """Check if current user can access admin interface""" + return flask_login.current_user.is_authenticated + + def inaccessible_callback(self, name, **kwargs): + """Redirect to login if not accessible""" + flash("Please login to access this page.", "danger") + return redirect(url_for("admin.login_view", next=request.url)) + + @staticmethod + def _list_thumbnail(view, _context, model, _name): + data = "" + data += f'' + + return Markup(data) + + column_formatters = { + "path": _list_thumbnail, + } diff --git a/server/config/__init__.py b/server/config/__init__.py index 69377f8..8da2596 100644 --- a/server/config/__init__.py +++ b/server/config/__init__.py @@ -26,11 +26,16 @@ class AppConfig: db_pass_salt: str = field("DB_PASS_SALT", "") db_search_path: str = field("DB_SEARCH_PATH", "beerds") - fs_local_mount_dir: str = field("FS_LOCAL_MOUNT_DIR", default="./files") + fs_local_mount_dir: str = field("FS_LOCAL_MOUNT_DIR", default="files/") fs_s3_bucket: str = field("FS_S3_BUCKET", "") fs_s3_access_key_id: str = field("FS_ACCESS_KEY_ID", "") fs_s3_access_key: str = field("FS_SECRET_ACCESS_KEY", "") fs_s3_endpoint: str = field("FS_S3_ENDPOINT", "") + fs_dir: str = field("FS_DIR", "beerds") + + admin_secret_key: bool = field("ADMIN_SECRET_KEY", default="default") + admin_login: bool = field("ADMIN_LOGIN", default="admin") + admin_pass: bool = field("ADMIN_PASS", default="123456") @lru_cache diff --git a/server/modules/attachments/repository/attachments.py b/server/modules/attachments/repository/attachments.py index 9545ce2..2c14754 100644 --- a/server/modules/attachments/repository/attachments.py +++ b/server/modules/attachments/repository/attachments.py @@ -14,6 +14,11 @@ class AttachmentRepository(metaclass=ABCMeta): """Get Attachment by ID""" pass + @abstractmethod + async def get_by_path(self, session: AbstractSession, path: list[str]) -> list[Attachment]: + """Get Attachment by ID""" + pass + @abstractmethod async def create(self, data: Attachment): """Create entry in DB""" @@ -95,6 +100,15 @@ class MockAttachmentRepository(AttachmentRepository): f.append(f_item) return f + async def get_by_path(self, session: AbstractSession, path: list[str]) -> list[Attachment]: + f: list[Attachment] = [] + for f_path in path: + for key in self._data: + if self._data[key].path != f_path: + continue + f.append(self._data[key]) + return f + async def create(self, data: Attachment): self._data[data.id] = data @@ -124,6 +138,16 @@ class DBAttachmentRepository(AttachmentRepository): attachment.append(d[0]) return attachment + async def get_by_path(self, session: AbstractSession, path: list[str]) -> list[Attachment]: + q = select(Attachment).where( + Attachment.path.in_(path) # type: ignore + ) + attachment: list[Attachment] = [] + result: CursorResult[tuple[Attachment]] = await session.execute(q) # type: ignore + for d in result.all(): + attachment.append(d[0]) + return attachment + async def create(self, data: Attachment): new_dict = data.to_serializable(delete_private=True, time_to_str=False) insert_data = insert(Attachment).values(**new_dict) diff --git a/server/modules/attachments/services/attachment.py b/server/modules/attachments/services/attachment.py index ade86f4..1e53142 100644 --- a/server/modules/attachments/services/attachment.py +++ b/server/modules/attachments/services/attachment.py @@ -122,9 +122,8 @@ class MockStorageDriver(StorageDriver): class S3StorageDriver(StorageDriver): - _prefix: str = "beerds" - def __init__(self, cnf: AppConfig) -> None: + self._prefix = cnf.fs_dir self._chunk_size: int = 69 * 1024 self._cnf = cnf self._logger = get_logger() @@ -140,12 +139,14 @@ class S3StorageDriver(StorageDriver): return self._session.client("s3", endpoint_url=self._cnf.fs_s3_endpoint) def _normalize_path(self, path: str) -> str: - return f"{S3StorageDriver._prefix}{path}".replace(self._cnf.fs_local_mount_dir, "") + if path[0] == "/": + path = path[1:] + return str(Path(self._prefix) / Path(path)) async def put(self, data: bytes) -> str: sign = hashlib.file_digest(BytesIO(data), "sha256").hexdigest() day = datetime.now(UTC).strftime("%d") - path = str(Path(S3StorageDriver._prefix) / day / sign) + path = str(Path(self._prefix) / day / sign) async with await self._client() as s3: await s3.upload_fileobj( @@ -157,7 +158,7 @@ class S3StorageDriver(StorageDriver): "ChecksumSHA256": sign, }, ) - return path.replace(S3StorageDriver._prefix, "") + return path.replace(self._prefix, "") async def stream(self, path: str): path = self._normalize_path(path) @@ -232,11 +233,11 @@ class AtachmentService: return "jpg" return content_type.split("/")[1].split("+")[0] - def id_from_url(self, url: str) -> str: + def path_from_url(self, url: str) -> str: if ".original" not in url: raise ValueError(f"wrong url: {url}") parts = url.split(".original")[0] - return parts.replace("/", "") + return f"/{parts}" def url(self, attachment_id: str, content_type: str | None = None) -> str: return f"{self._cnf.app_public_url}/api/v0/attachment/{attachment_id}.original.{self.extension(content_type)}" @@ -257,7 +258,15 @@ class AtachmentService: await self._repository.create(attach) return attach.to_domain() - async def get_info(self, session: AbstractSession | None, attach_id: list[str]) -> list[Attachment]: + async def get_info_bypath(self, session: AbstractSession | None, path: list[str]) -> list[Attachment]: + if not path: + return [] + if session is not None: + return await self._repository.get_by_path(session, path) + async with self._repository.db().session_slave() as session: + return await self._repository.get_by_path(session, path) + + async def get_info_byid(self, session: AbstractSession | None, attach_id: list[str]) -> list[Attachment]: if not attach_id: return [] if session is not None: @@ -268,13 +277,10 @@ class AtachmentService: def get_name(self, attachment: Attachment) -> str: return f"{attachment.id}.{self.extension(attachment.content_type)}" - async def get_data(self, session: AbstractSession, attach_id: str) -> bytes | None: - file = await self._repository.get_by_id(session, [attach_id]) - if not file: - return None - return await self._driver.take(file[0].path) + async def get_data(self, path: str) -> bytes | None: + return await self._driver.take(path) - async def get_stream(self, session: AbstractSession | None, attach_id: str) -> AsyncIterator[bytes]: + async def get_stream(self, path: str) -> AsyncIterator[bytes]: async def _stream_iterator(is_empty: bool): if is_empty: return @@ -282,15 +288,7 @@ class AtachmentService: async for chunk in stream: # type: ignore yield chunk - if session: - file = await self._repository.get_by_id(session, [attach_id]) - else: - async with self._repository.db().session_slave() as session: - file = await self._repository.get_by_id(session, [attach_id]) - if not file: - raise FileNotFoundError - - stream = self._driver.stream(file[0].path) + stream = self._driver.stream(path) try: first_chunk = await stream.__anext__() # type: ignore except StopAsyncIteration: @@ -298,7 +296,7 @@ class AtachmentService: return _stream_iterator(is_empty=False) async def image_resize(self, session: AbstractSession, attach_id: list[str]): - info = await self.get_info(session, attach_id) + info = await self.get_info_byid(session, attach_id) get_logger().info( f"image_resize {len(info)}", ) @@ -309,7 +307,7 @@ class AtachmentService: continue if item.is_deleted: continue - data = await self.get_data(session, item.id) + data = await self.get_data(item.path) if data is None: continue if len(data) <= RESIZE_MAX_SIZE: diff --git a/uv.lock b/uv.lock index e8f7183..a97c499 100644 --- a/uv.lock +++ b/uv.lock @@ -19,6 +19,11 @@ dependencies = [ { name = "asyncpg" }, { name = "betterconf" }, { name = "dataclasses-ujson" }, + { name = "flask", extra = ["async"] }, + { name = "flask-admin" }, + { name = "flask-login" }, + { name = "flask-sqlalchemy" }, + { name = "flask-wtf" }, { name = "granian" }, { name = "inject" }, { name = "jinja2" }, @@ -39,6 +44,7 @@ dependencies = [ { name = "types-requests" }, { name = "ujson" }, { name = "uvicorn" }, + { name = "werkzeug" }, ] [package.optional-dependencies] @@ -62,6 +68,11 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.31.0" }, { name = "betterconf", specifier = ">=4.5.0" }, { name = "dataclasses-ujson", specifier = ">=0.0.34" }, + { name = "flask", extras = ["async"], specifier = ">=3.1.2" }, + { name = "flask-admin", specifier = ">=2.0.2" }, + { name = "flask-login", specifier = ">=0.6.3" }, + { name = "flask-sqlalchemy", specifier = ">=3.1.1" }, + { name = "flask-wtf", specifier = ">=1.2.2" }, { name = "granian", specifier = "==2.5" }, { name = "inject", specifier = ">=5.3.0" }, { name = "jinja2", specifier = ">=3.1.6" }, @@ -90,6 +101,7 @@ requires-dist = [ { name = "types-requests", marker = "extra == 'default'", specifier = ">=2.32.0.20250328" }, { name = "ujson", specifier = ">=5.11.0" }, { name = "uvicorn", specifier = ">=0.38.0" }, + { name = "werkzeug", specifier = ">=3.1.5" }, ] provides-extras = ["default"] @@ -281,6 +293,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/24/44299477fe7dcc9cb58d0a57d5a7588d6af2ff403fdd2d47a246c91a3246/anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5", size = 80896 }, ] +[[package]] +name = "asgiref" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096 }, +] + [[package]] name = "asyncpg" version = "0.31.0" @@ -331,6 +352,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/3c/86921e4e407f25413819e04471606c32377f47887af7873e0613f5036946/betterconf-4.5.0-py3-none-any.whl", hash = "sha256:ee6ad8ae4c49a7f977555dcb435eb258eb044e10a2f43fdd77bb67b8ff682118", size = 11769 }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, +] + [[package]] name = "boto3" version = "1.40.61" @@ -525,6 +555,84 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054 }, ] +[[package]] +name = "flask" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308 }, +] + +[package.optional-dependencies] +async = [ + { name = "asgiref" }, +] + +[[package]] +name = "flask-admin" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, + { name = "wtforms" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/b7/78a1534b4fe1a40bccf79e8e274e1ace6ee1678c804e4c178b1c96e6c8d6/flask_admin-2.0.2.tar.gz", hash = "sha256:1d06aec7efee957972b43f6b08a0bd08d5f4cf9a337d4ece2f17c98abc2a214e", size = 5528977 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/25/a379b8dc388630f1d173544c8b3ebbdb2ad5b60992e531b724d7f8a7ea03/flask_admin-2.0.2-py3-none-any.whl", hash = "sha256:4b3c44068de0fe4630dfcd190cc11231cbbdd7bac315c74c55d1764087b8b273", size = 6459099 }, +] + +[[package]] +name = "flask-login" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303 }, +] + +[[package]] +name = "flask-sqlalchemy" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125 }, +] + +[[package]] +name = "flask-wtf" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "itsdangerous" }, + { name = "wtforms" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/9b/f1cd6e41bbf874f3436368f2c7ee3216c1e82d666ff90d1d800e20eb1317/flask_wtf-1.2.2.tar.gz", hash = "sha256:79d2ee1e436cf570bccb7d916533fa18757a2f18c290accffab1b9a0b684666b", size = 42641 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/19/354449145fbebb65e7c621235b6ad69bebcfaec2142481f044d0ddc5b5c5/flask_wtf-1.2.2-py3-none-any.whl", hash = "sha256:e93160c5c5b6b571cf99300b6e01b72f9a101027cab1579901f8b10c5daf0b70", size = 12779 }, +] + [[package]] name = "fonttools" version = "4.60.1" @@ -773,6 +881,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6e/b00ef8fe9a43aa3a6f5687b710832f0d876c0812bd0ce1c3af3e71bf7dd1/inject-5.3.0-py2.py3-none-any.whl", hash = "sha256:4758eb6c464d3e2badbbf65ac991c64752b05429d6af4c3c0e5b2765efaf7e73", size = 14349 }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -2103,6 +2220,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109 }, ] +[[package]] +name = "werkzeug" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025 }, +] + [[package]] name = "wrapt" version = "1.17.3" @@ -2142,6 +2271,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591 }, ] +[[package]] +name = "wtforms" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/e4/633d080897e769ed5712dcfad626e55dbd6cf45db0ff4d9884315c6a82da/wtforms-3.2.1.tar.gz", hash = "sha256:df3e6b70f3192e92623128123ec8dca3067df9cfadd43d59681e210cfb8d4682", size = 137801 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/c9/2088fb5645cd289c99ebe0d4cdcc723922a1d8e1beaefb0f6f76dff9b21c/wtforms-3.2.1-py3-none-any.whl", hash = "sha256:583bad77ba1dd7286463f21e11aa3043ca4869d03575921d1a1698d0715e0fd4", size = 152454 }, +] + [[package]] name = "yarl" version = "1.22.0"