Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

upload images to local storage #182

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion admin_ui/src/components/ArrayWidget.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,25 @@
:value="value"
id="choice"
v-on:change="updateArray($event, index)"
v-if="!schema.media_columns.includes(title.toLowerCase())"
/>
<input
v-else
type="image"
:src="value"
id="image"
v-on:click.prevent
/>
<a href="#" v-on:click.prevent="removeArrayElement(index)">
<font-awesome-icon icon="times" />
</a>
</li>
<li>
<a href="#" v-on:click.prevent="addArrayElement">
<a
href="#"
v-on:click.prevent="addArrayElement"
v-if="!schema.media_columns.includes(title.toLowerCase())"
>
<font-awesome-icon icon="plus" />{{ $t("Add") }}
</a>
</li>
Expand All @@ -31,13 +43,22 @@ export default {
inputType: {
type: String,
default: "text"
},
title: {
type: String,
default: ""
}
},
data() {
return {
internalArray: []
}
},
computed: {
schema() {
return this.$store.state.schema
}
},
methods: {
updateArray($event, index) {
this.$set(this.internalArray, index, $event.target.value)
Expand Down Expand Up @@ -84,6 +105,11 @@ ul.array_items {
margin-right: 0.5rem;
margin-bottom: 0 !important;
}

#image {
max-width: 25%;
cursor: alias;
}
}
}
</style>
52 changes: 51 additions & 1 deletion admin_ui/src/components/InputField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,28 @@
</template>

<template v-else-if="type == 'array'">
<div
v-if="
schema.media_columns.includes(
getFieldName(title).toLowerCase()
)
"
>
<form
id="uploadForm"
enctype="multipart/form-data"
v-on:change="uploadFile($event)"
>
<label>
Upload image
<input type="file" name="file" />
</label>
</form>
</div>
<ArrayWidget
:array="localValue"
v-on:updateArray="localValue = $event"
:title="getFieldName(title)"
/>
<input
:value="JSON.stringify(localValue)"
Expand All @@ -160,7 +179,7 @@

<script lang="ts">
import Vue, { PropType } from "vue"

import axios from "axios"
import flatPickr from "vue-flatpickr-component"

import ArrayWidget from "./ArrayWidget.vue"
Expand Down Expand Up @@ -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: {
Expand All @@ -269,6 +299,8 @@ export default Vue.extend({
</script>

<style scoped lang="less">
@import "../vars.less";

pre {
white-space: pre-wrap;
word-break: break-all;
Expand All @@ -283,4 +315,22 @@ input.flatpicker-input {
textarea#editor {
display: none;
}

input[type="file"] {
display: none;
}

label {
cursor: pointer;
border: none;
padding: 0.8rem 1.2rem;
font-weight: bolder;
margin-top: 1rem;
transition: background-color 0.5s;
background-color: @dark_blue;
color: white;
font-size: 0.7em;
text-transform: uppercase;
text-align: center;
}
</style>
26 changes: 25 additions & 1 deletion admin_ui/src/views/RowListing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,21 @@
| abbreviate
}}</pre>
</span>
<span
v-else-if="
schema.media_columns.includes(
name
)
"
>
<img
:key="key"
v-for="(image, key) in row[
name
]"
:src="image"
/>
</span>
<span v-else>{{
row[name] | abbreviate
}}</span>
Expand Down Expand Up @@ -255,7 +270,10 @@
<li>
<DeleteButton
:includeTitle="true"
class="subtle delete"
class="
subtle
delete
"
v-on:triggered="
deleteRow(
row[pkName]
Expand Down Expand Up @@ -752,4 +770,10 @@ div.wrapper {
}
}
}

img {
width: 4rem;
height: 4rem;
padding: 0.2rem;
}
</style>
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ piccolo, password: piccolo123).
../table_config/index
../custom_forms/index
../actions/index
../media_upload/index
../internationalization/index
../contributing/index
../rest_documentation/index
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
72 changes: 72 additions & 0 deletions docs/source/media_upload/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
.. _Media Upload:

Media Upload
============

.. image:: ./images/media_upload.png

Piccolo Admin has the option of uploading media files to local static folder
(local storage). Multi file upload is enabled per record for all columns
specified in ``media_columns``.


Usage
-----

Piccolo admin uses the Piccolo ORM `Array <https://piccolo-orm.readthedocs.io/en/latest/piccolo/schema/column_types.html#array>`_
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% sure about it having to be an Array column. I can imagine situations where someone wants an array, but I can also imagine situations where someone wants to use a Varchar, as they only want a single image to be uploaded.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The input type file is set to upload only one file, so the user can easily upload only one image. For uploading multiple files Array column is ideal (we already have a ArrayWidget for adding and removing fields which is very useful), so this solves the uploading of multiple or single file in one solution.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, in some situations though you want to make sure the user can only upload one file.

Imagine you're building a user profile table, and you have a field called 'profile_image'. If there are multiple images stored, it will be confusing - which is the genuine one?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made 2 videos to see what I mean.
Single file upload

single_file.mp4

Multiple files upload

multiple_file.mp4

But of course you can do it however you want, but I think this is a simple and efficient solution because we actually upload only one file each time.

column for media uploads. You must specify the column in the table and then register
the media column in the ``TableConfig`` ``media_columns``
so that the admin UI can display the file upload button for that field by
inspecting the JSON schema. The final step is to write your own media handler that
upload the files and register handler to ``TableConfig`` ``media_handler``.

Full example:

.. code-block:: python

import shutil
import typing as t
import uuid
from abc import ABC, abstractmethod
from piccolo.columns import Array, Varchar
from piccolo_admin.endpoints import (
MEDIA_PATH,
TableConfig,
create_admin
)

class Movie(Table):
poster = Array(base_column=Varchar())


# An abstract class as a blueprint, allowing you to write your
# own upload method to suit your needs.
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)

movie_config = TableConfig(
table_class=Movie,
media_columns=[Movie.poster],
media_handler=media_handler,
)

APP = create_admin([movie_config])

34 changes: 33 additions & 1 deletion piccolo_admin/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from datetime import timedelta
from functools import partial

from fastapi import FastAPI
from fastapi import FastAPI, File, UploadFile
from piccolo.apps.user.tables import BaseUser
from piccolo.columns.base import Column
from piccolo.columns.reference import LazyTableReference
Expand Down Expand Up @@ -48,6 +48,7 @@
from .version import __VERSION__ as PICCOLO_ADMIN_VERSION

ASSET_PATH = os.path.join(os.path.dirname(__file__), "dist")
MEDIA_PATH = os.path.join(os.path.dirname(__file__), "media")


class UserResponseModel(BaseModel):
Expand Down Expand Up @@ -101,6 +102,8 @@ class TableConfig:
exclude_visible_filters: t.Optional[t.List[Column]] = None
rich_text_columns: t.Optional[t.List[Column]] = None
hooks: t.Optional[t.List[Hook]] = None
media_columns: t.Optional[t.List[Column]] = None
media_handler: t.Optional[t.Any] = None

def __post_init__(self):
if self.visible_columns and self.exclude_visible_columns:
Expand Down Expand Up @@ -157,6 +160,13 @@ def get_rich_text_columns_names(self) -> 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:
Expand Down Expand Up @@ -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])
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
Loading