diff --git a/admin_ui/src/components/ArrayWidget.vue b/admin_ui/src/components/ArrayWidget.vue
index 943d440e..175424bb 100644
--- a/admin_ui/src/components/ArrayWidget.vue
+++ b/admin_ui/src/components/ArrayWidget.vue
@@ -7,13 +7,25 @@
:value="value"
id="choice"
v-on:change="updateArray($event, index)"
+ v-if="!schema.media_columns.includes(title.toLowerCase())"
+ />
+
-
+
{{ $t("Add") }}
@@ -31,6 +43,10 @@ export default {
inputType: {
type: String,
default: "text"
+ },
+ title: {
+ type: String,
+ default: ""
}
},
data() {
@@ -38,6 +54,11 @@ export default {
internalArray: []
}
},
+ computed: {
+ schema() {
+ return this.$store.state.schema
+ }
+ },
methods: {
updateArray($event, index) {
this.$set(this.internalArray, index, $event.target.value)
@@ -84,6 +105,11 @@ ul.array_items {
margin-right: 0.5rem;
margin-bottom: 0 !important;
}
+
+ #image {
+ max-width: 25%;
+ cursor: alias;
+ }
}
}
diff --git a/admin_ui/src/components/InputField.vue b/admin_ui/src/components/InputField.vue
index 31ce97b7..35aff29b 100644
--- a/admin_ui/src/components/InputField.vue
+++ b/admin_ui/src/components/InputField.vue
@@ -145,9 +145,28 @@
+
+
+
import Vue, { PropType } from "vue"
-
+import axios from "axios"
import flatPickr from "vue-flatpickr-component"
import ArrayWidget from "./ArrayWidget.vue"
@@ -249,6 +268,17 @@ export default Vue.extend({
},
updateLocalValue(event) {
this.localValue = event
+ },
+ async uploadFile(event) {
+ const file = event.target.files[0]
+ let formData = new FormData()
+ formData.append("file", file)
+ const response = await axios.post("./api/media", formData, {
+ headers: {
+ "Content-Type": "multipart/form-data"
+ }
+ })
+ this.localValue.push(response.data.image)
}
},
watch: {
@@ -269,6 +299,8 @@ export default Vue.extend({
diff --git a/admin_ui/src/views/RowListing.vue b/admin_ui/src/views/RowListing.vue
index 8727117a..c25ad6f4 100644
--- a/admin_ui/src/views/RowListing.vue
+++ b/admin_ui/src/views/RowListing.vue
@@ -199,6 +199,21 @@
| abbreviate
}}
+
+
+
{{
row[name] | abbreviate
}}
@@ -255,7 +270,10 @@
t.Tuple[str, ...]:
else ()
)
+ def get_media_columns_names(self) -> t.Tuple[str, ...]:
+ return (
+ tuple(i._meta.name for i in self.media_columns)
+ if self.media_columns
+ else ()
+ )
+
@dataclass
class FormConfig:
@@ -332,6 +342,7 @@ def __init__(
rich_text_columns_names = (
table_config.get_rich_text_columns_names()
)
+ media_columns_names = table_config.get_media_columns_names()
validators = (
Validators(every=[superuser_validators])
@@ -350,6 +361,7 @@ def __init__(
"visible_column_names": visible_column_names,
"visible_filter_names": visible_filter_names,
"rich_text_columns": rich_text_columns_names,
+ "media_columns": media_columns_names,
},
validators=validators,
hooks=table_config.hooks,
@@ -406,6 +418,13 @@ def __init__(
response_model=UserResponseModel,
)
+ private_app.add_api_route(
+ path="/media/",
+ endpoint=self.store_files, # type: ignore
+ methods=["POST"],
+ tags=["Media"],
+ )
+
private_app.add_route(
path="/change-password/",
route=change_password(
@@ -486,6 +505,11 @@ def __init__(
app=StaticFiles(directory=os.path.join(ASSET_PATH, "js")),
)
+ self.mount(
+ path="/media",
+ app=StaticFiles(directory=MEDIA_PATH),
+ )
+
auth_middleware = partial(
AuthenticationMiddleware,
backend=SessionsAuthBackend(
@@ -505,6 +529,14 @@ async def get_root(self, request: Request) -> HTMLResponse:
###########################################################################
+ def store_files(self, request: Request, file: UploadFile = File(...)):
+ for table_class in self.table_configs:
+ if table_class.media_handler is not None:
+ media_file = table_class.media_handler
+ return media_file.upload(request, file)
+
+ ###########################################################################
+
def get_user(self, request: Request) -> UserResponseModel:
return UserResponseModel(
username=request.user.display_name,
diff --git a/piccolo_admin/example.py b/piccolo_admin/example.py
index a66c42d3..795294d6 100644
--- a/piccolo_admin/example.py
+++ b/piccolo_admin/example.py
@@ -10,8 +10,11 @@
import enum
import os
import random
+import shutil
import smtplib
import typing as t
+import uuid
+from abc import ABC, abstractmethod
import targ
from hypercorn.asyncio import serve
@@ -40,7 +43,12 @@
from piccolo_api.session_auth.tables import SessionsBase
from pydantic import BaseModel, validator
-from piccolo_admin.endpoints import FormConfig, TableConfig, create_admin
+from piccolo_admin.endpoints import (
+ MEDIA_PATH,
+ FormConfig,
+ TableConfig,
+ create_admin,
+)
from piccolo_admin.example_data import DIRECTORS, MOVIE_WORDS, MOVIES, STUDIOS
@@ -101,6 +109,7 @@ class Genre(int, enum.Enum):
oscar_nominations = Integer()
won_oscar = Boolean()
description = Text()
+ poster = Array(base_column=Varchar())
release_date = Timestamp(null=True)
box_office = Numeric(digits=(5, 1), help_text="In millions of US dollars.")
tags = Array(base_column=Varchar())
@@ -180,6 +189,28 @@ async def booking_endpoint(request, data):
return "Booking complete"
+class MediaHandler(ABC):
+ @abstractmethod
+ def upload(self, request, file):
+ pass
+
+
+class LocalMediaHandler(MediaHandler):
+ def __init__(self, root_path: str) -> None:
+ self.root_path = root_path
+
+ def upload(self, request, file) -> t.Dict[str, str]:
+ image = f"{self.root_path}/{uuid.uuid4()}.jpeg"
+ image_path = "/".join(image.split("/")[-2:])
+ with open(image, "wb") as buffer:
+ shutil.copyfileobj(file.file, buffer)
+ url_path = dict(request.scope["headers"]).get(b"host", b"").decode()
+ return {"image": f"{request.url.scheme}://{url_path}/{image_path}"}
+
+
+media_handler = LocalMediaHandler(root_path=MEDIA_PATH)
+
+
TABLE_CLASSES: t.Tuple[t.Type[Table], ...] = (
Director,
Movie,
@@ -195,7 +226,8 @@ async def booking_endpoint(request, data):
Movie.name,
Movie.rating,
Movie.director,
- Movie.studio,
+ Movie.poster,
+ Movie.tags,
],
visible_filters=[
Movie.name,
@@ -205,6 +237,8 @@ async def booking_endpoint(request, data):
Movie.genre,
],
rich_text_columns=[Movie.description],
+ media_columns=[Movie.poster],
+ media_handler=media_handler,
)
director_config = TableConfig(
diff --git a/piccolo_admin/media/.gitkeep b/piccolo_admin/media/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py
index 1847fa04..27a9496f 100644
--- a/tests/test_endpoints.py
+++ b/tests/test_endpoints.py
@@ -1,4 +1,5 @@
import datetime
+from pathlib import Path
from unittest import TestCase
from unittest.mock import MagicMock
@@ -15,7 +16,12 @@
from piccolo_api.session_auth.tables import SessionsBase
from starlette.testclient import TestClient
-from piccolo_admin.endpoints import TableConfig, create_admin, get_all_tables
+from piccolo_admin.endpoints import (
+ MEDIA_PATH,
+ TableConfig,
+ create_admin,
+ get_all_tables,
+)
from piccolo_admin.example import APP
from piccolo_admin.translations.data import ENGLISH, FRENCH, TRANSLATIONS
from piccolo_admin.version import __VERSION__
@@ -315,6 +321,48 @@ def test_post_form_fail(self):
self.assertEqual(response.status_code, 400)
+class TestUpload(TestCase):
+ credentials = {"username": "Bob", "password": "bob123"}
+
+ def setUp(self):
+ create_db_tables_sync(SessionsBase, BaseUser, if_not_exists=True)
+ BaseUser.create_user_sync(
+ **self.credentials, active=True, admin=True, superuser=True
+ )
+
+ def tearDown(self):
+ drop_db_tables_sync(SessionsBase, BaseUser)
+
+ def test_image_upload(self):
+ client = TestClient(APP)
+
+ # To get a CSRF cookie
+ response = client.get("/")
+ csrftoken = response.cookies["csrftoken"]
+
+ # Login
+ payload = dict(csrftoken=csrftoken, **self.credentials)
+ client.post(
+ "/public/login/",
+ json=payload,
+ headers={"X-CSRFToken": csrftoken},
+ )
+
+ response = client.post(
+ "/api/media/",
+ files={"file": ("1234", "filename", "image/jpeg")},
+ headers={"X-CSRFToken": csrftoken},
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue("image" in response.json())
+
+ # remove the test file from the static directory
+ image = response.json()["image"]
+ file_name = image.split("/")[-1]
+ test_file = Path(MEDIA_PATH, file_name)
+ test_file.unlink()
+
+
class TestTables(TestCase):
credentials = {"username": "Bob", "password": "bob123"}