admin
Gitea Actions Demo / build_and_push (push) Successful in 27m53s Details

This commit is contained in:
artem 2026-01-16 12:51:28 +03:00
parent b5a59f4d3f
commit 0a0fea33ac
14 changed files with 486 additions and 27 deletions

1
.gitignore vendored
View File

@ -176,3 +176,4 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
.env.local

View File

@ -32,6 +32,12 @@ dependencies = [
"aioboto3>=15.0.0", "aioboto3>=15.0.0",
"python-magic>=0.4.27", "python-magic>=0.4.27",
"psycopg2-binary>=2.9.11", "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] [project.optional-dependencies]

0
server/admin/__init__.py Normal file
View File

124
server/admin/__main__.py Normal file
View File

@ -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/<path:raw_path>", 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)

47
server/admin/auth.py Normal file
View File

@ -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()

19
server/admin/config.py Normal file
View File

@ -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)

View File

@ -0,0 +1,40 @@
{% extends 'admin/master.html' %}
{% block body %}
{{ super() }}
<div class="row-fluid">
<div>
{% if current_user.is_authenticated %}
<h1>Flask-Admin example</h1>
<p class="lead">
Authentication
</p>
<p>
This example shows how you can use Flask-Login for authentication. It is only intended as a basic demonstration.
</p>
{% else %}
<form method="POST" action="">
{{ form.hidden_tag() if form.hidden_tag }}
{% for f in form if f.type != 'CSRFTokenField' %}
<div class="form-group">
{{ f.label }}<br>
{{ f }}
{% if f.errors %}
<ul>
{% for e in f.errors %}
<li>{{ e }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
<button class="btn btn-primary" type="submit">Submit</button>
</form>
{{ link | safe }}
{% endif %}
</div>
<a class="btn btn-primary" href="/"><i class="icon-arrow-left icon-white"></i> Back</a>
</div>
{% endblock body %}

View File

@ -0,0 +1,7 @@
<html>
<body>
<div>
<a href="{{ url_for('admin.index') }}">Go to admin!</a>
</div>
</body>
</html>

View File

@ -0,0 +1,15 @@
{% extends 'admin/base.html' %}
{% block access_control %}
{% if current_user.is_authenticated %}
<div class="dropdown show">
<a class="btn btn-secondary dropdown-toggle" href="#" role="button" id="dropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="icon-user"></i> {{ current_user }} <span class="caret"></span>
</a>
<div class="dropdown-menu" aria-labelledby="dropdownMenuLink">
<a class="btn" href="{{ url_for('admin.logout_view') }}">Log out</a>
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -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'<img src="/admin/attachment/{model.path}.original.jpg" width="100" />'
return Markup(data)
column_formatters = {
"path": _list_thumbnail,
}

View File

@ -26,11 +26,16 @@ class AppConfig:
db_pass_salt: str = field("DB_PASS_SALT", "") db_pass_salt: str = field("DB_PASS_SALT", "")
db_search_path: str = field("DB_SEARCH_PATH", "beerds") 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_bucket: str = field("FS_S3_BUCKET", "")
fs_s3_access_key_id: str = field("FS_ACCESS_KEY_ID", "") fs_s3_access_key_id: str = field("FS_ACCESS_KEY_ID", "")
fs_s3_access_key: str = field("FS_SECRET_ACCESS_KEY", "") fs_s3_access_key: str = field("FS_SECRET_ACCESS_KEY", "")
fs_s3_endpoint: str = field("FS_S3_ENDPOINT", "") 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 @lru_cache

View File

@ -14,6 +14,11 @@ class AttachmentRepository(metaclass=ABCMeta):
"""Get Attachment by ID""" """Get Attachment by ID"""
pass pass
@abstractmethod
async def get_by_path(self, session: AbstractSession, path: list[str]) -> list[Attachment]:
"""Get Attachment by ID"""
pass
@abstractmethod @abstractmethod
async def create(self, data: Attachment): async def create(self, data: Attachment):
"""Create entry in DB""" """Create entry in DB"""
@ -95,6 +100,15 @@ class MockAttachmentRepository(AttachmentRepository):
f.append(f_item) f.append(f_item)
return f 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): async def create(self, data: Attachment):
self._data[data.id] = data self._data[data.id] = data
@ -124,6 +138,16 @@ class DBAttachmentRepository(AttachmentRepository):
attachment.append(d[0]) attachment.append(d[0])
return attachment 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): async def create(self, data: Attachment):
new_dict = data.to_serializable(delete_private=True, time_to_str=False) new_dict = data.to_serializable(delete_private=True, time_to_str=False)
insert_data = insert(Attachment).values(**new_dict) insert_data = insert(Attachment).values(**new_dict)

View File

@ -122,9 +122,8 @@ class MockStorageDriver(StorageDriver):
class S3StorageDriver(StorageDriver): class S3StorageDriver(StorageDriver):
_prefix: str = "beerds"
def __init__(self, cnf: AppConfig) -> None: def __init__(self, cnf: AppConfig) -> None:
self._prefix = cnf.fs_dir
self._chunk_size: int = 69 * 1024 self._chunk_size: int = 69 * 1024
self._cnf = cnf self._cnf = cnf
self._logger = get_logger() self._logger = get_logger()
@ -140,12 +139,14 @@ class S3StorageDriver(StorageDriver):
return self._session.client("s3", endpoint_url=self._cnf.fs_s3_endpoint) return self._session.client("s3", endpoint_url=self._cnf.fs_s3_endpoint)
def _normalize_path(self, path: str) -> str: 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: async def put(self, data: bytes) -> str:
sign = hashlib.file_digest(BytesIO(data), "sha256").hexdigest() sign = hashlib.file_digest(BytesIO(data), "sha256").hexdigest()
day = datetime.now(UTC).strftime("%d") 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: async with await self._client() as s3:
await s3.upload_fileobj( await s3.upload_fileobj(
@ -157,7 +158,7 @@ class S3StorageDriver(StorageDriver):
"ChecksumSHA256": sign, "ChecksumSHA256": sign,
}, },
) )
return path.replace(S3StorageDriver._prefix, "") return path.replace(self._prefix, "")
async def stream(self, path: str): async def stream(self, path: str):
path = self._normalize_path(path) path = self._normalize_path(path)
@ -232,11 +233,11 @@ class AtachmentService:
return "jpg" return "jpg"
return content_type.split("/")[1].split("+")[0] 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: if ".original" not in url:
raise ValueError(f"wrong url: {url}") raise ValueError(f"wrong url: {url}")
parts = url.split(".original")[0] parts = url.split(".original")[0]
return parts.replace("/", "") return f"/{parts}"
def url(self, attachment_id: str, content_type: str | None = None) -> str: 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)}" 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) await self._repository.create(attach)
return attach.to_domain() 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: if not attach_id:
return [] return []
if session is not None: if session is not None:
@ -268,13 +277,10 @@ class AtachmentService:
def get_name(self, attachment: Attachment) -> str: def get_name(self, attachment: Attachment) -> str:
return f"{attachment.id}.{self.extension(attachment.content_type)}" return f"{attachment.id}.{self.extension(attachment.content_type)}"
async def get_data(self, session: AbstractSession, attach_id: str) -> bytes | None: async def get_data(self, path: str) -> bytes | None:
file = await self._repository.get_by_id(session, [attach_id]) return await self._driver.take(path)
if not file:
return None
return await self._driver.take(file[0].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): async def _stream_iterator(is_empty: bool):
if is_empty: if is_empty:
return return
@ -282,15 +288,7 @@ class AtachmentService:
async for chunk in stream: # type: ignore async for chunk in stream: # type: ignore
yield chunk yield chunk
if session: stream = self._driver.stream(path)
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)
try: try:
first_chunk = await stream.__anext__() # type: ignore first_chunk = await stream.__anext__() # type: ignore
except StopAsyncIteration: except StopAsyncIteration:
@ -298,7 +296,7 @@ class AtachmentService:
return _stream_iterator(is_empty=False) return _stream_iterator(is_empty=False)
async def image_resize(self, session: AbstractSession, attach_id: list[str]): 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( get_logger().info(
f"image_resize {len(info)}", f"image_resize {len(info)}",
) )
@ -309,7 +307,7 @@ class AtachmentService:
continue continue
if item.is_deleted: if item.is_deleted:
continue continue
data = await self.get_data(session, item.id) data = await self.get_data(item.path)
if data is None: if data is None:
continue continue
if len(data) <= RESIZE_MAX_SIZE: if len(data) <= RESIZE_MAX_SIZE:

141
uv.lock
View File

@ -19,6 +19,11 @@ dependencies = [
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "betterconf" }, { name = "betterconf" },
{ name = "dataclasses-ujson" }, { name = "dataclasses-ujson" },
{ name = "flask", extra = ["async"] },
{ name = "flask-admin" },
{ name = "flask-login" },
{ name = "flask-sqlalchemy" },
{ name = "flask-wtf" },
{ name = "granian" }, { name = "granian" },
{ name = "inject" }, { name = "inject" },
{ name = "jinja2" }, { name = "jinja2" },
@ -39,6 +44,7 @@ dependencies = [
{ name = "types-requests" }, { name = "types-requests" },
{ name = "ujson" }, { name = "ujson" },
{ name = "uvicorn" }, { name = "uvicorn" },
{ name = "werkzeug" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@ -62,6 +68,11 @@ requires-dist = [
{ name = "asyncpg", specifier = ">=0.31.0" }, { name = "asyncpg", specifier = ">=0.31.0" },
{ name = "betterconf", specifier = ">=4.5.0" }, { name = "betterconf", specifier = ">=4.5.0" },
{ name = "dataclasses-ujson", specifier = ">=0.0.34" }, { 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 = "granian", specifier = "==2.5" },
{ name = "inject", specifier = ">=5.3.0" }, { name = "inject", specifier = ">=5.3.0" },
{ name = "jinja2", specifier = ">=3.1.6" }, { name = "jinja2", specifier = ">=3.1.6" },
@ -90,6 +101,7 @@ requires-dist = [
{ name = "types-requests", marker = "extra == 'default'", specifier = ">=2.32.0.20250328" }, { name = "types-requests", marker = "extra == 'default'", specifier = ">=2.32.0.20250328" },
{ name = "ujson", specifier = ">=5.11.0" }, { name = "ujson", specifier = ">=5.11.0" },
{ name = "uvicorn", specifier = ">=0.38.0" }, { name = "uvicorn", specifier = ">=0.38.0" },
{ name = "werkzeug", specifier = ">=3.1.5" },
] ]
provides-extras = ["default"] 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 }, { 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]] [[package]]
name = "asyncpg" name = "asyncpg"
version = "0.31.0" 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 }, { 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]] [[package]]
name = "boto3" name = "boto3"
version = "1.40.61" 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 }, { 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]] [[package]]
name = "fonttools" name = "fonttools"
version = "4.60.1" 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 }, { 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]] [[package]]
name = "jinja2" name = "jinja2"
version = "3.1.6" 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 }, { 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]] [[package]]
name = "wrapt" name = "wrapt"
version = "1.17.3" 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 }, { 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]] [[package]]
name = "yarl" name = "yarl"
version = "1.22.0" version = "1.22.0"