✨ Added basic upload endpoint
This commit is contained in:
@@ -37,6 +37,8 @@ scheduler = "*"
|
|||||||
webauthn = "*"
|
webauthn = "*"
|
||||||
pyotp = "*"
|
pyotp = "*"
|
||||||
minio = "*"
|
minio = "*"
|
||||||
|
xxhash = "*"
|
||||||
|
arq = "*"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
coverage = "*"
|
coverage = "*"
|
||||||
|
|||||||
Generated
+504
-308
File diff suppressed because it is too large
Load Diff
+1
-12
@@ -1,7 +1,6 @@
|
|||||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
import asyncio
|
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
@@ -11,7 +10,6 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||||||
|
|
||||||
from classquiz.config import settings
|
from classquiz.config import settings
|
||||||
from classquiz.db import database
|
from classquiz.db import database
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from classquiz.oauth import rememberme_middleware
|
from classquiz.oauth import rememberme_middleware
|
||||||
from classquiz.routers import (
|
from classquiz.routers import (
|
||||||
@@ -36,8 +34,7 @@ from classquiz.routers import (
|
|||||||
quiztivity,
|
quiztivity,
|
||||||
)
|
)
|
||||||
from classquiz.socket_server import sio
|
from classquiz.socket_server import sio
|
||||||
from classquiz.helpers import meilisearch_init, telemetry_ping, bg_tasks
|
from classquiz.helpers import meilisearch_init, telemetry_ping
|
||||||
from scheduler.asyncio import Scheduler
|
|
||||||
|
|
||||||
settings = settings()
|
settings = settings()
|
||||||
if settings.sentry_dsn:
|
if settings.sentry_dsn:
|
||||||
@@ -58,13 +55,6 @@ async def sentry_exception(request: Request, call_next):
|
|||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|
||||||
async def background_tasks():
|
|
||||||
schedule = Scheduler()
|
|
||||||
schedule.cyclic(timedelta(hours=6), bg_tasks.clean_editor_images_up)
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup() -> None:
|
async def startup() -> None:
|
||||||
database_ = app.state.database
|
database_ = app.state.database
|
||||||
@@ -72,7 +62,6 @@ async def startup() -> None:
|
|||||||
await database_.connect()
|
await database_.connect()
|
||||||
await meilisearch_init()
|
await meilisearch_init()
|
||||||
await telemetry_ping()
|
await telemetry_ping()
|
||||||
asyncio.create_task(background_tasks())
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
|
|||||||
+13
-1
@@ -9,6 +9,8 @@ import redis as redis_base_lib
|
|||||||
from pydantic import BaseSettings, RedisDsn, PostgresDsn, BaseModel
|
from pydantic import BaseSettings, RedisDsn, PostgresDsn, BaseModel
|
||||||
import meilisearch as MeiliSearch
|
import meilisearch as MeiliSearch
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from arq import create_pool
|
||||||
|
from arq.connections import RedisSettings, ArqRedis
|
||||||
|
|
||||||
from classquiz.storage import Storage
|
from classquiz.storage import Storage
|
||||||
|
|
||||||
@@ -70,12 +72,22 @@ class Settings(BaseSettings):
|
|||||||
env_nested_delimiter = "__"
|
env_nested_delimiter = "__"
|
||||||
|
|
||||||
|
|
||||||
|
async def initialize_arq():
|
||||||
|
global arq
|
||||||
|
arq = await create_pool(RedisSettings.from_dsn(settings.redis))
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
@lru_cache()
|
||||||
def settings() -> Settings:
|
def settings() -> Settings:
|
||||||
return Settings()
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
redis: redis_base_lib.client.Redis = redis_lib.Redis().from_url(settings().redis)
|
# asyncio.run(initialize_arq())
|
||||||
|
|
||||||
|
pool = redis_lib.ConnectionPool().from_url(settings().redis)
|
||||||
|
|
||||||
|
redis: redis_base_lib.client.Redis = redis_lib.Redis(connection_pool=pool)
|
||||||
|
arq: ArqRedis = ArqRedis(pool_or_conn=pool)
|
||||||
storage: Storage = Storage(
|
storage: Storage = Storage(
|
||||||
backend=settings().storage_backend,
|
backend=settings().storage_backend,
|
||||||
deta_key=settings().deta_project_key,
|
deta_key=settings().deta_project_key,
|
||||||
|
|||||||
@@ -378,3 +378,23 @@ class PublicQuizTivityShare(BaseModel):
|
|||||||
quiztivity=OnlyId(id=data.quiztivity.id),
|
quiztivity=OnlyId(id=data.quiztivity.id),
|
||||||
user=OnlyId(id=data.user.id),
|
user=OnlyId(id=data.user.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StorageItem(ormar.Model):
|
||||||
|
id: uuid.UUID = ormar.UUID(primary_key=True)
|
||||||
|
uploaded_at: datetime = ormar.DateTime(nullable=False, default=datetime.now())
|
||||||
|
mime_type: str = ormar.Text(nullable=False)
|
||||||
|
hash: bytes | None = ormar.LargeBinary(nullable=True, min_length=16, max_length=16)
|
||||||
|
user: User | None = ormar.ForeignKey(User)
|
||||||
|
size: int = ormar.BigInteger(nullable=False)
|
||||||
|
storage_path: str | None = ormar.Text(nullable=True)
|
||||||
|
deleted_at: datetime | None = ormar.DateTime(nullable=True, default=None)
|
||||||
|
quiztivities: list[QuizTivity] | None = ormar.ManyToMany(QuizTivity)
|
||||||
|
quizzes: list[Quiz] | None = ormar.ManyToMany(Quiz)
|
||||||
|
alt_text: str | None = ormar.Text(default=None, nullable=True)
|
||||||
|
filename: str | None = ormar.Text(default=None, nullable=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
tablename = "storage_items"
|
||||||
|
metadata = metadata
|
||||||
|
database = database
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
|
||||||
from fastapi.responses import StreamingResponse, RedirectResponse
|
from fastapi.responses import StreamingResponse, RedirectResponse
|
||||||
|
|
||||||
from classquiz.config import settings, storage
|
from classquiz.auth import get_current_user
|
||||||
|
from classquiz.config import settings, storage, arq
|
||||||
|
from classquiz.db.models import User, StorageItem
|
||||||
from classquiz.storage.errors import DownloadingFailedError
|
from classquiz.storage.errors import DownloadingFailedError
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
settings = settings()
|
settings = settings()
|
||||||
|
|
||||||
@@ -39,3 +42,32 @@ async def download_file(file_name: str):
|
|||||||
media_type="image/*",
|
media_type="image/*",
|
||||||
headers={"Cache-Control": "public, immutable, max-age=31536000"},
|
headers={"Cache-Control": "public, immutable, max-age=31536000"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
async def upload_file(file: UploadFile = File(), user: User = Depends(get_current_user)):
|
||||||
|
file_id = uuid4()
|
||||||
|
|
||||||
|
size = 0
|
||||||
|
# if file.file.name is None:
|
||||||
|
# size = len(await file.read())
|
||||||
|
# print("MemorySize", size)
|
||||||
|
# else:
|
||||||
|
# f = file.file
|
||||||
|
# a = file.file.fileno()
|
||||||
|
# os.path.getsize(file.file.name)
|
||||||
|
# print("DiskSize", size, "name:", file.file.name, "a:", file.file.tell(), "size")
|
||||||
|
file_obj = StorageItem(
|
||||||
|
id=file_id,
|
||||||
|
uploaded_at=datetime.now(),
|
||||||
|
mime_type=file.content_type,
|
||||||
|
hash=None,
|
||||||
|
user=user,
|
||||||
|
size=size,
|
||||||
|
deleted_at=None,
|
||||||
|
alt_text=None,
|
||||||
|
)
|
||||||
|
file_data = await file.read()
|
||||||
|
await storage.upload(file_name=file_id.hex, file_data=file_data)
|
||||||
|
await file_obj.save()
|
||||||
|
await arq.enqueue_job("calculate_hash", file_id.hex)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
from arq import cron
|
||||||
|
from arq.connections import RedisSettings
|
||||||
|
|
||||||
|
from classquiz import settings
|
||||||
|
from classquiz.db import database
|
||||||
|
from classquiz.worker.storage import clean_editor_images_up, calculate_hash
|
||||||
|
|
||||||
|
|
||||||
|
async def startup(ctx):
|
||||||
|
ctx["db"] = database
|
||||||
|
if not ctx["db"].is_connected:
|
||||||
|
await ctx["db"].connect()
|
||||||
|
|
||||||
|
|
||||||
|
async def shutdown(ctx):
|
||||||
|
if ctx["db"].is_connected:
|
||||||
|
await ctx["db"].disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerSettings:
|
||||||
|
# functions = [add_track]
|
||||||
|
functions = [calculate_hash]
|
||||||
|
cron_jobs = [cron(clean_editor_images_up, hour={0, 6, 12, 18}, minute=0)]
|
||||||
|
on_startup = startup
|
||||||
|
on_shutdown = shutdown
|
||||||
|
redis_settings = RedisSettings.from_dsn(settings.redis)
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import xxhash
|
||||||
|
|
||||||
from classquiz.config import redis, storage
|
from classquiz.config import redis, storage
|
||||||
|
from tempfile import SpooledTemporaryFile
|
||||||
|
|
||||||
|
from classquiz.db.models import StorageItem
|
||||||
from classquiz.storage.errors import DeletionFailedError
|
from classquiz.storage.errors import DeletionFailedError
|
||||||
|
|
||||||
|
|
||||||
async def clean_editor_images_up():
|
async def clean_editor_images_up(ctx):
|
||||||
print("Cleaning images up")
|
print("Cleaning images up")
|
||||||
edit_sessions = await redis.smembers("edit_sessions")
|
edit_sessions = await redis.smembers("edit_sessions")
|
||||||
for session_id in edit_sessions:
|
for session_id in edit_sessions:
|
||||||
@@ -19,3 +26,22 @@ async def clean_editor_images_up():
|
|||||||
print("Deletion Error", images)
|
print("Deletion Error", images)
|
||||||
await redis.srem("edit_sessions", session_id)
|
await redis.srem("edit_sessions", session_id)
|
||||||
await redis.delete(f"edit_session:{session_id}:images")
|
await redis.delete(f"edit_session:{session_id}:images")
|
||||||
|
|
||||||
|
|
||||||
|
async def calculate_hash(ctx, file_id_as_str: str):
|
||||||
|
print("Calculating hash")
|
||||||
|
file_id = uuid.UUID(file_id_as_str)
|
||||||
|
file_data = await StorageItem.objects.get(id=file_id)
|
||||||
|
file_path = file_id.hex
|
||||||
|
if file_data.storage_path is not None:
|
||||||
|
file_path = file_data.storage_path
|
||||||
|
file = SpooledTemporaryFile()
|
||||||
|
file.write((await storage.download(file_path)).getbuffer().tobytes())
|
||||||
|
hash_obj = xxhash.xxh3_128()
|
||||||
|
# assert hash_obj.block_size == 64
|
||||||
|
while chunk := file.read(6400):
|
||||||
|
hash_obj.update(chunk)
|
||||||
|
file_data.hash = hash_obj.digest()
|
||||||
|
print("Got hash!")
|
||||||
|
await file_data.update()
|
||||||
|
file.close()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Added StorageItem
|
||||||
|
|
||||||
|
Revision ID: 44255816ff7b
|
||||||
|
Revises: 8ac2bed1718e
|
||||||
|
Create Date: 2023-05-25 12:29:44.913484
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import ormar
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "44255816ff7b"
|
||||||
|
down_revision = "8ac2bed1718e"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"storage_items",
|
||||||
|
sa.Column("id", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=False),
|
||||||
|
sa.Column("uploaded_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("mime_type", sa.Text(), nullable=False),
|
||||||
|
sa.Column("hash", sa.LargeBinary(length=16), nullable=True),
|
||||||
|
sa.Column("user", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True),
|
||||||
|
sa.Column("size", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("storage_path", sa.Text(), nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("alt_text", sa.Text(), nullable=True),
|
||||||
|
sa.Column("filename", sa.Text(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["user"], ["users.id"], name="fk_storage_items_users_id_user"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"storageitems_quizs",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("quiz", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True),
|
||||||
|
sa.Column("storageitem", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["quiz"], ["quiz.id"], name="fk_storageitems_quizs_quiz_quiz_id", onupdate="CASCADE", ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["storageitem"],
|
||||||
|
["storage_items.id"],
|
||||||
|
name="fk_storageitems_quizs_storage_items_storageitem_id",
|
||||||
|
onupdate="CASCADE",
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"storageitems_quiztivitys",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("quiztivity", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True),
|
||||||
|
sa.Column("storageitem", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["quiztivity"],
|
||||||
|
["quiztivitys.id"],
|
||||||
|
name="fk_storageitems_quiztivitys_quiztivitys_quiztivity_id",
|
||||||
|
onupdate="CASCADE",
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["storageitem"],
|
||||||
|
["storage_items.id"],
|
||||||
|
name="fk_storageitems_quiztivitys_storage_items_storageitem_id",
|
||||||
|
onupdate="CASCADE",
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table("storageitems_quiztivitys")
|
||||||
|
op.drop_table("storageitems_quizs")
|
||||||
|
op.drop_table("storage_items")
|
||||||
|
# ### end Alembic commands ###
|
||||||
Reference in New Issue
Block a user