Added basic upload endpoint

This commit is contained in:
Mawoka
2023-05-25 13:41:55 +02:00
parent 79adb925ce
commit 7222fb5632
9 changed files with 714 additions and 325 deletions
+2
View File
@@ -37,6 +37,8 @@ scheduler = "*"
webauthn = "*"
pyotp = "*"
minio = "*"
xxhash = "*"
arq = "*"
[dev-packages]
coverage = "*"
Generated
+504 -308
View File
File diff suppressed because it is too large Load Diff
+1 -12
View File
@@ -1,7 +1,6 @@
# 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/.
import asyncio
import sentry_sdk
from fastapi import FastAPI, Request
@@ -11,7 +10,6 @@ from starlette.middleware.sessions import SessionMiddleware
from classquiz.config import settings
from classquiz.db import database
from datetime import timedelta
from classquiz.oauth import rememberme_middleware
from classquiz.routers import (
@@ -36,8 +34,7 @@ from classquiz.routers import (
quiztivity,
)
from classquiz.socket_server import sio
from classquiz.helpers import meilisearch_init, telemetry_ping, bg_tasks
from scheduler.asyncio import Scheduler
from classquiz.helpers import meilisearch_init, telemetry_ping
settings = settings()
if settings.sentry_dsn:
@@ -58,13 +55,6 @@ async def sentry_exception(request: Request, call_next):
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")
async def startup() -> None:
database_ = app.state.database
@@ -72,7 +62,6 @@ async def startup() -> None:
await database_.connect()
await meilisearch_init()
await telemetry_ping()
asyncio.create_task(background_tasks())
@app.on_event("shutdown")
+13 -1
View File
@@ -9,6 +9,8 @@ import redis as redis_base_lib
from pydantic import BaseSettings, RedisDsn, PostgresDsn, BaseModel
import meilisearch as MeiliSearch
from typing import Optional
from arq import create_pool
from arq.connections import RedisSettings, ArqRedis
from classquiz.storage import Storage
@@ -70,12 +72,22 @@ class Settings(BaseSettings):
env_nested_delimiter = "__"
async def initialize_arq():
global arq
arq = await create_pool(RedisSettings.from_dsn(settings.redis))
@lru_cache()
def settings() -> 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(
backend=settings().storage_backend,
deta_key=settings().deta_project_key,
+20
View File
@@ -378,3 +378,23 @@ class PublicQuizTivityShare(BaseModel):
quiztivity=OnlyId(id=data.quiztivity.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
+35 -3
View File
@@ -1,14 +1,17 @@
# 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/.
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 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 uuid import uuid4
settings = settings()
@@ -39,3 +42,32 @@ async def download_file(file_name: str):
media_type="image/*",
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)
+29
View File
@@ -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
# 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/.
import uuid
import xxhash
from classquiz.config import redis, storage
from tempfile import SpooledTemporaryFile
from classquiz.db.models import StorageItem
from classquiz.storage.errors import DeletionFailedError
async def clean_editor_images_up():
async def clean_editor_images_up(ctx):
print("Cleaning images up")
edit_sessions = await redis.smembers("edit_sessions")
for session_id in edit_sessions:
@@ -19,3 +26,22 @@ async def clean_editor_images_up():
print("Deletion Error", images)
await redis.srem("edit_sessions", session_id)
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 ###