admin
Gitea Actions Demo / build_and_push (push) Successful in 27m53s
Details
Gitea Actions Demo / build_and_push (push) Successful in 27m53s
Details
This commit is contained in:
parent
b5a59f4d3f
commit
0a0fea33ac
|
|
@ -176,3 +176,4 @@ cython_debug/
|
||||||
|
|
||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
.env.local
|
||||||
|
|
@ -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,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)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('admin.index') }}">Go to admin!</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
141
uv.lock
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue