Repository: piccolo-orm/piccolo_admin (language: python)

Issue:
Move `MediaStorage` / `LocalMediaStorage` / `S3MediaStorage` to `piccolo_api`
I would like to integrate our media storage code with `PiccoloCRUD` at some point. So really, the media storage code should live in Piccolo API (otherwise we will have circular dependency between `piccolo_admin` and `piccolo_api`).

So everything in `piccolo_admin/media` should move to `piccolo_api/media`. Likewise, with the associate tests.

The docs can stay as part of Piccolo Admin for now. We just need to update the imports so they're from `piccolo_api.media`.


Discussion / hints:
@sinisaos What do you think about this?

Just wondering whether to do it before releasing the next version of Piccolo Admin.
@dantownsend I think it's a good idea because, as you said, we will prevent circular dependency, but as I mentioned before, it would be very nice and useful for the end user, because they would have everything prepared on the backend and only need to make the frontend.
@dantownsend If you want I can make these changes in Piccolo API and Piccolo Admin? Of course, you'll have to check if I did everything right.
@sinisaos If you wouldn't mind, that would be great - thanks
@dantownsend I will do it during the day.

Fix patch:
diff --git a/docs/source/api_reference/index.rst b/docs/source/api_reference/index.rst
index 9882246f..a58fca7a 100644
--- a/docs/source/api_reference/index.rst
+++ b/docs/source/api_reference/index.rst
@@ -12,7 +12,7 @@ Translations
 MediaStorage
 ------------
 
-.. currentmodule:: piccolo_admin.media.base
+.. currentmodule:: piccolo_api.media.base
 
 .. autoclass:: MediaStorage
     :members:
diff --git a/docs/source/media_storage/index.rst b/docs/source/media_storage/index.rst
index d1208105..8e6cc9ef 100644
--- a/docs/source/media_storage/index.rst
+++ b/docs/source/media_storage/index.rst
@@ -96,11 +96,11 @@ MediaStorage
 ------------
 
 For each column we want to use for media storage, we associate it with a
-:class:`MediaStorage <piccolo_admin.media.base.MediaStorage>` instance.
+:class:`MediaStorage <piccolo_api.media.base.MediaStorage>` instance.
 
-Out of the box we have two subclasses - :class:`LocalMediaStorage <piccolo_admin.media.local.LocalMediaStorage>`
-and :class:`S3MediaStorage <piccolo_admin.media.s3.S3MediaStorage>`.
-You can also create your own subclass of :class:`MediaStorage <piccolo_admin.media.base.MediaStorage>`
+Out of the box we have two subclasses - :class:`LocalMediaStorage <piccolo_api.media.local.LocalMediaStorage>`
+and :class:`S3MediaStorage <piccolo_api.media.s3.S3MediaStorage>`.
+You can also create your own subclass of :class:`MediaStorage <piccolo_api.media.base.MediaStorage>`
 to implement your own storage backend.
 
 
@@ -123,7 +123,7 @@ In order to associate a column with ``LocalMediaStorage``, we do the following:
         TableConfig,
         create_admin
     )
-    from piccolo_admin.media.local import LocalMediaStorage
+    from piccolo_api.media.local import LocalMediaStorage
 
 
     class Movie(Table):
@@ -169,7 +169,7 @@ Some things to be aware of:
 Source
 ~~~~~~
 
-.. currentmodule:: piccolo_admin.media.local
+.. currentmodule:: piccolo_api.media.local
 
 .. autoclass:: LocalMediaStorage
 
@@ -194,7 +194,7 @@ In order to associate a column with ``S3MediaStorage``, we do the following:
         TableConfig,
         create_admin
     )
-    from piccolo_admin.media.s3 import S3MediaStorage
+    from piccolo_api.media.s3 import S3MediaStorage
 
 
     class Movie(Table):
@@ -251,7 +251,7 @@ Some things to be aware of:
 Source
 ~~~~~~
 
-.. currentmodule:: piccolo_admin.media.s3
+.. currentmodule:: piccolo_api.media.s3
 
 .. autoclass:: S3MediaStorage
 
@@ -273,7 +273,7 @@ For example:
     await MOVIE_POSTER_MEDIA.delete_file('some-file-key.jpeg')
 
 
-To see all of the methods available, look at :class:`MediaStorage <piccolo_admin.media.base.MediaStorage>`.
+To see all of the methods available, look at :class:`MediaStorage <piccolo_api.media.base.MediaStorage>`.
 
 .. _CleaningUpFiles:
 
diff --git a/piccolo_admin/endpoints.py b/piccolo_admin/endpoints.py
index 4665c372..28738232 100644
--- a/piccolo_admin/endpoints.py
+++ b/piccolo_admin/endpoints.py
@@ -24,6 +24,8 @@
 from piccolo_api.crud.validators import Validators
 from piccolo_api.csrf.middleware import CSRFMiddleware
 from piccolo_api.fastapi.endpoints import FastAPIKwargs, FastAPIWrapper
+from piccolo_api.media.base import MediaStorage
+from piccolo_api.media.local import LocalMediaStorage
 from piccolo_api.openapi.endpoints import swagger_ui
 from piccolo_api.rate_limiting.middleware import (
     InMemoryLimitProvider,
@@ -40,8 +42,6 @@
 from starlette.responses import HTMLResponse, JSONResponse
 from starlette.staticfiles import StaticFiles
 
-from .media.base import MediaStorage
-from .media.local import LocalMediaStorage
 from .translations.data import TRANSLATIONS
 from .translations.models import (
     Translation,
diff --git a/piccolo_admin/example.py b/piccolo_admin/example.py
index 4291001d..490dfd6a 100644
--- a/piccolo_admin/example.py
+++ b/piccolo_admin/example.py
@@ -37,13 +37,13 @@
 from piccolo.engine.postgres import PostgresEngine
 from piccolo.engine.sqlite import SQLiteEngine
 from piccolo.table import Table
+from piccolo_api.media.local import LocalMediaStorage
+from piccolo_api.media.s3 import S3MediaStorage
 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.example_data import DIRECTORS, MOVIE_WORDS, MOVIES, STUDIOS
-from piccolo_admin.media.local import LocalMediaStorage
-from piccolo_admin.media.s3 import S3MediaStorage
 
 try:
     """
diff --git a/piccolo_admin/media/__init__.py b/piccolo_admin/media/__init__.py
deleted file mode 100644
index 3c6609d5..00000000
--- a/piccolo_admin/media/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-"""
-Resources:
-https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
-"""
diff --git a/piccolo_admin/media/base.py b/piccolo_admin/media/base.py
deleted file mode 100644
index b768efad..00000000
--- a/piccolo_admin/media/base.py
+++ /dev/null
@@ -1,316 +0,0 @@
-from __future__ import annotations
-
-import abc
-import asyncio
-import logging
-import pathlib
-import string
-import typing as t
-import uuid
-
-from piccolo.apps.user.tables import BaseUser
-from piccolo.columns.column_types import Array, Text, Varchar
-
-logger = logging.getLogger(__file__)
-
-
-#: Pass into ``allowed_characters`` to just allow audio files.
-AUDIO_EXTENSIONS = (
-    "mp3",
-    "wav",
-)
-
-#: Pass into ``allowed_characters`` to just allow data files.
-DATA_EXTENSIONS = (
-    "csv",
-    "tsv",
-)
-
-#: Pass into ``allowed_characters`` to just allow document files.
-DOCUMENT_EXTENSIONS = ("pdf",)
-
-#: Pass into ``allowed_characters`` to just allow image files.
-IMAGE_EXTENSIONS = (
-    "gif",
-    "jpeg",
-    "jpg",
-    "png",
-    "svg",
-    "tif",
-    "webp",
-)
-
-#: Pass into ``allowed_characters`` to just allow text files.
-TEXT_EXTENSIONS = (
-    "rtf",
-    "txt",
-)
-
-#: Pass into ``allowed_characters`` to just allow video files.
-VIDEO_EXTENSIONS = ("mov", "mp4", "webm")
-
-#: These are the extensions which are allowed by default.
-ALLOWED_EXTENSIONS = (
-    *AUDIO_EXTENSIONS,
-    *DATA_EXTENSIONS,
-    *DOCUMENT_EXTENSIONS,
-    *IMAGE_EXTENSIONS,
-    *TEXT_EXTENSIONS,
-    *VIDEO_EXTENSIONS,
-)
-
-#: These are the characters allowed in the file name by default.
-ALLOWED_CHARACTERS = (
-    *string.ascii_letters,
-    *string.digits,
-    " ",
-    "-",
-    "_",
-    ".",
-)
-
-
-ALLOWED_COLUMN_TYPES = (Varchar, Text)
-
-
-# TODO - might move this to Piccolo API.
-class MediaStorage(metaclass=abc.ABCMeta):
-    """
-    If you want to implement your own custom storage backend, create a subclass
-    of this class. Override each abstract method.
-
-    Typically, just use :class:`LocalMediaStorage <piccolo_admin.media.local.LocalMediaStorage>`
-    or :class:`S3MediaStorage <piccolo_admin.media.s3.S3MediaStorage>` instead.
-
-    """  # noqa: E501
-
-    def __init__(
-        self,
-        column: t.Union[Text, Varchar, Array],
-        allowed_extensions: t.Optional[t.Sequence[str]] = ALLOWED_EXTENSIONS,
-        allowed_characters: t.Optional[t.Sequence[str]] = ALLOWED_CHARACTERS,
-    ):
-        if not (
-            isinstance(column, ALLOWED_COLUMN_TYPES)
-            or (
-                isinstance(column, Array)
-                and isinstance(column.base_column, ALLOWED_COLUMN_TYPES)
-            )
-        ):
-            raise ValueError(
-                "The column must be a `Varchar`, `Text`, or `Array`."
-            )
-
-        self.column = column
-        self.allowed_extensions = (
-            [i.lower() for i in allowed_extensions]
-            if allowed_extensions
-            else None
-        )
-        self.allowed_characters = allowed_characters
-
-    def validate_file_name(self, file_name: str):
-        """
-        :raises ValueError:
-            If the file name is invalid.
-
-        """
-        if not file_name:
-            # It's unlikely that the file_name is an empty string, but just in
-            # case.
-            raise ValueError("The file name can't be empty.")
-
-        # Don't allow the file name to begin with a dot, otherwise it will be a
-        # hidden file on Unix.
-        if file_name.startswith("."):
-            raise ValueError("File names must not start with a period.")
-
-        # Don't allow double dots in the file name, in case it allows a file to
-        # be written to a parent folder.
-        if ".." in file_name:
-            raise ValueError("File names must not contain '..'.")
-
-        if self.allowed_characters:
-            # Having some restriction on the allowed characters is important,
-            # in case there are things like null bytes in there.
-            # https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html#extension-validation
-            for character in file_name:
-                if character not in self.allowed_characters:
-                    raise ValueError(
-                        f"'{character}' is not allowed in the filename."
-                    )
-
-        components = file_name.rsplit(".", 1)
-
-        if len(components) == 2:
-            _, extension = components
-
-            if self.allowed_extensions:
-                if extension.lower() not in self.allowed_extensions:
-                    # TODO - inspect the files to make sure they are what
-                    # they say they are.
-                    raise ValueError("This file type isn't allowed.")
-        else:
-            raise ValueError("The file has no extension.")
-
-    def generate_file_key(
-        self, file_name: str, user: t.Optional[BaseUser] = None
-    ) -> str:
-        """
-        Generates a unique file ID. If you have your own strategy for naming
-        files, you can override this method.
-
-        By default we add a UUID to the filename, to make it unique::
-
-            >>> self.generate_file_key(file_name='my-poster.jpg')
-            my-poster-3beac950-7721-46c9-9e7d-5e908ef51011.jpg
-
-        :raises ValueError:
-            If the ``file_name`` is invalid.
-
-        """
-        # If the file_name includes the entire path (e.g. /foo/bar.jpg) - we
-        # just want bar.jpg.
-        file_name = pathlib.Path(file_name).name
-
-        self.validate_file_name(file_name=file_name)
-
-        name, extension = file_name.rsplit(".", 1)
-
-        if len(file_name) > 50:
-            # Truncate really long names. Otherwise they might be too long
-            # for some operating systems, or too long to be stored in a
-            # database field.
-            name = name[:50]
-
-        uuid_ = uuid.uuid4()
-
-        file_key = f"{name}-{uuid_}"
-        if extension:
-            file_key += f".{extension}"
-
-        return file_key
-
-    ###########################################################################
-
-    @abc.abstractmethod
-    async def store_file(
-        self, file_name: str, file: t.IO, user: t.Optional[BaseUser] = None
-    ) -> str:
-        """
-        Stores the file in whichever storage you're using, and returns a key
-        which uniquely identifes the file.
-
-        :param file:
-            The file to store.
-        :param user:
-            The Piccolo ``BaseUser`` who requested this.
-
-        """
-        raise NotImplementedError  # pragma: no cover
-
-    @abc.abstractmethod
-    async def generate_file_url(
-        self, file_key: str, root_url: str, user: t.Optional[BaseUser] = None
-    ):
-        """
-        This retrieves an absolute URL for the file. It might be a signed URL,
-        if using S3 for storage.
-
-        :param file_key:
-            Get the URL for a file with this file_key.
-        :param root_url:
-            The URL the media is usually served from. The sub class might
-            ignore this argument entirely, if it's fetching the data from
-            an external source like S3.
-        :param user:
-            The Piccolo ``BaseUser`` who requested this.
-        """
-        raise NotImplementedError  # pragma: no cover
-
-    @abc.abstractmethod
-    async def get_file(self, file_key: str) -> t.Optional[t.IO]:
-        """
-        Returns the file object matching the ``file_key``.
-        """
-        raise NotImplementedError  # pragma: no cover
-
-    @abc.abstractmethod
-    async def delete_file(self, file_key: str):
-        """
-        Deletes the file object matching the ``file_key``.
-        """
-        raise NotImplementedError  # pragma: no cover
-
-    @abc.abstractmethod
-    async def bulk_delete_files(self, file_keys: t.List[str]):
-        raise NotImplementedError  # pragma: no cover
-
-    @abc.abstractmethod
-    async def get_file_keys(self) -> t.List[str]:
-        """
-        Returns the file key for each file we have stored.
-        """
-        raise NotImplementedError  # pragma: no cover
-
-    ###########################################################################
-
-    async def get_file_keys_from_db(self) -> t.List[str]:
-        """
-        Returns the file key for each file we have in the database.
-        """
-        table = self.column._meta.table
-        return await table.select(self.column).output(as_list=True)
-
-    async def get_unused_file_keys(self) -> t.List[str]:
-        """
-        Compares the file keys we have stored, vs what's in the database.
-        """
-        db_keys, disk_keys = await asyncio.gather(
-            self.get_file_keys_from_db(), self.get_file_keys()
-        )
-        return list(set(disk_keys) - set(db_keys))
-
-    async def delete_unused_files(
-        self, number_shown: int = 10, auto: bool = False
-    ):
-        """
-        Over time, you will end up with files stored which are no longer
-        needed. For example, if a row is deleted in the database, which
-        referenced a stored file.
-
-        By periodically running this method, it will clean up these unused
-        files.
-
-        It's important that each column uses its own folder for storing files.
-        If multiple columns store data in the same folder, then we could
-        delete some files which are needed by another column.
-
-        :param number_shown:
-            This number of unused file names are printed out, so you can be
-            sure nothing strange is going on.
-        :param auto:
-            If ``True``, no confirmation is required before deletion takes
-            place.
-
-        """
-        unused_file_keys = await self.get_unused_file_keys()
-
-        number_unused = len(unused_file_keys)
-
-        print(f"There are {number_unused} unused files.")
-
-        if number_unused:
-            print("Here are some examples:")
-            print("\n".join(i for i in unused_file_keys[:number_shown]))
-
-            if auto or (
-                input("Would you like to delete them? Enter y to confirm")
-                == "y"
-            ):
-                await self.bulk_delete_files(unused_file_keys)
-
-    def __eq__(self, value):
-        if not isinstance(value, MediaStorage):
-            return False
-        return value.__hash__() == self.__hash__()
diff --git a/piccolo_admin/media/local.py b/piccolo_admin/media/local.py
deleted file mode 100644
index c3ce104d..00000000
--- a/piccolo_admin/media/local.py
+++ /dev/null
@@ -1,188 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import functools
-import logging
-import os
-import pathlib
-import shutil
-import typing as t
-from concurrent.futures import ThreadPoolExecutor
-
-from piccolo.apps.user.tables import BaseUser
-from piccolo.columns.column_types import Array, Text, Varchar
-from piccolo.utils.sync import run_sync
-
-from .base import ALLOWED_CHARACTERS, ALLOWED_EXTENSIONS, MediaStorage
-
-if t.TYPE_CHECKING:  # pragma: no cover
-    from concurrent.futures._base import Executor
-
-
-logger = logging.getLogger(__file__)
-
-
-class LocalMediaStorage(MediaStorage):
-    def __init__(
-        self,
-        column: t.Union[Text, Varchar, Array],
-        media_path: str,
-        executor: t.Optional[Executor] = None,
-        allowed_extensions: t.Optional[t.Sequence[str]] = ALLOWED_EXTENSIONS,
-        allowed_characters: t.Optional[t.Sequence[str]] = ALLOWED_CHARACTERS,
-        file_permissions: t.Optional[int] = 0o640,
-    ):
-        """
-        Stores media files on a local path. This is good for simple
-        applications, where you're happy with the media files being stored
-        on a single server.
-
-        :param column:
-            The Piccolo ``Column`` which the storage is for.
-        :param media_path:
-            This is the local folder where the media files will be stored. It
-            should be an absolute path. For example,
-            ``'/srv/piccolo-media/poster/'``.
-        :param executor:
-            An executor, which file save operations are run in, to avoid
-            blocking the event loop. If not specified, we use a sensibly
-            configured :class:`ThreadPoolExecutor <concurrent.futures.ThreadPoolExecutor>`.
-        :param allowed_extensions:
-            Which file extensions are allowed. If ``None``, then all extensions
-            are allowed (not recommended unless the users are trusted).
-        :param allowed_characters:
-            Which characters are allowed in the file name. By default, it's
-            very strict. If set to ``None`` then all characters are allowed.
-        :param file_permissions:
-            If set to a value other than ``None``, then all uploaded files are
-            given these file permissions.
-        """  # noqa: E501
-        self.media_path = media_path
-        self.executor = executor or ThreadPoolExecutor(max_workers=10)
-        self.file_permissions = file_permissions
-
-        if not os.path.exists(media_path):
-            os.mkdir(self.media_path)
-
-        super().__init__(
-            column=column,
-            allowed_extensions=allowed_extensions,
-            allowed_characters=allowed_characters,
-        )
-
-    async def store_file(
-        self, file_name: str, file: t.IO, user: t.Optional[BaseUser] = None
-    ) -> str:
-        # If the file_name includes the entire path (e.g. /foo/bar.jpg) - we
-        # just want bar.jpg.
-        file_name = pathlib.Path(file_name).name
-
-        file_key = self.generate_file_key(file_name=file_name, user=user)
-
-        loop = asyncio.get_running_loop()
-        file_permissions = self.file_permissions
-
-        def save():
-            path = os.path.join(self.media_path, file_key)
-
-            if os.path.exists(path):
-                logger.error(
-                    "A file name clash has occurred - the chances are very "
-                    "low. Could be malicious, or a serious bug."
-                )
-                raise IOError("Unable to save the file")
-
-            with open(path, "wb") as new_file:
-                shutil.copyfileobj(file, new_file)
-                if file_permissions is not None:
-                    os.chmod(path, 0o640)
-
-        await loop.run_in_executor(self.executor, save)
-
-        return file_key
-
-    def store_file_sync(
-        self, file_name: str, file: t.IO, user: t.Optional[BaseUser] = None
-    ) -> str:
-        """
-        A sync wrapper around :meth:`store_file`.
-        """
-        return run_sync(
-            self.store_file(file_name=file_name, file=file, user=user)
-        )
-
-    async def generate_file_url(
-        self, file_key: str, root_url: str, user: t.Optional[BaseUser] = None
-    ) -> str:
-        """
-        This retrieves an absolute URL for the file.
-        """
-        return "/".join((root_url.rstrip("/"), file_key))
-
-    def generate_file_url_sync(
-        self, file_key: str, root_url: str, user: t.Optional[BaseUser] = None
-    ) -> str:
-        """
-        A sync wrapper around :meth:`generate_file_url`.
-        """
-        return run_sync(
-            self.generate_file_url(
-                file_key=file_key, root_url=root_url, user=user
-            )
-        )
-
-    ###########################################################################
-
-    async def get_file(self, file_key: str) -> t.Optional[t.IO]:
-        """
-        Returns the file object matching the ``file_key``.
-        """
-        loop = asyncio.get_running_loop()
-        func = functools.partial(self.get_file_sync, file_key=file_key)
-        return await loop.run_in_executor(self.executor, func)
-
-    def get_file_sync(self, file_key: str) -> t.Optional[t.IO]:
-        """
-        A sync wrapper around :meth:`get_file`.
-        """
-        path = os.path.join(self.media_path, file_key)
-        return open(path, "rb")
-
-    async def delete_file(self, file_key: str):
-        """
-        Deletes the file object matching the ``file_key``.
-        """
-        loop = asyncio.get_running_loop()
-        func = functools.partial(self.delete_file_sync, file_key=file_key)
-        return await loop.run_in_executor(self.executor, func)
-
-    def delete_file_sync(self, file_key: str):
-        """
-        A sync wrapper around :meth:`delete_file`.
-        """
-        path = os.path.join(self.media_path, file_key)
-        os.unlink(path)
-
-    async def bulk_delete_files(self, file_keys: t.List[str]):
-        media_path = self.media_path
-        for file_key in file_keys:
-            os.unlink(os.path.join(media_path, file_key))
-
-    async def get_file_keys(self) -> t.List[str]:
-        """
-        Returns the file key for each file we have stored.
-        """
-        file_keys = []
-        for (_, _, filenames) in os.walk(self.media_path):
-            file_keys.extend(filenames)
-            break
-
-        return file_keys
-
-    def __hash__(self):
-        return hash(("local", self.media_path))
-
-    def __eq__(self, value):
-        if not isinstance(value, LocalMediaStorage):
-            return False
-        return value.__hash__() == self.__hash__()
diff --git a/piccolo_admin/media/s3.py b/piccolo_admin/media/s3.py
deleted file mode 100644
index 4b444a77..00000000
--- a/piccolo_admin/media/s3.py
+++ /dev/null
@@ -1,317 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import functools
-import pathlib
-import sys
-import typing as t
-from concurrent.futures import ThreadPoolExecutor
-
-from piccolo.apps.user.tables import BaseUser
-from piccolo.columns.column_types import Array, Text, Varchar
-
-from .base import ALLOWED_CHARACTERS, ALLOWED_EXTENSIONS, MediaStorage
-
-if t.TYPE_CHECKING:  # pragma: no cover
-    from concurrent.futures._base import Executor
-
-
-class S3MediaStorage(MediaStorage):
-    def __init__(
-        self,
-        column: t.Union[Text, Varchar, Array],
-        bucket_name: str,
-        folder_name: str,
-        connection_kwargs: t.Dict[str, t.Any] = None,
-        signed_url_expiry: int = 3600,
-        executor: t.Optional[Executor] = None,
-        allowed_extensions: t.Optional[t.Sequence[str]] = ALLOWED_EXTENSIONS,
-        allowed_characters: t.Optional[t.Sequence[str]] = ALLOWED_CHARACTERS,
-    ):
-        """
-        Stores media files in S3 compatible storage. This is a good option when
-        you have lots of files to store, and don't want them stored locally
-        on a server. Many cloud providers provide S3 compatible storage,
-        besides from Amazon Web Services.
-
-        :param column:
-            The Piccolo ``Column`` which the storage is for.
-        :param bucket_name:
-            Which S3 bucket the files are stored in.
-        :param folder:
-            The files will be stored in this folder within the bucket. S3
-            buckets don't really have folders, but if ``folder`` is
-            ``'movie_screenshots'``, then we store the file at
-            ``'movie_screenshots/my-file-abc-123.jpeg'``, to simulate it being
-            in a folder.
-        :param connection_kwargs:
-            These kwargs are passed directly to ``boto3``. Learn more about
-            `available options <https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/session.html#boto3.session.Session.client>`_.
-            For example::
-
-                S3MediaStorage(
-                    ...,
-                    connection_kwargs={
-                        'aws_access_key_id': 'abc123',
-                        'aws_secret_access_key': 'xyz789',
-                        'endpoint_url': 's3.cloudprovider.com',
-                        'region_name': 'uk'
-                    }
-                )
-
-        :param signed_url_expiry:
-            Files are accessed via signed URLs, which are only valid for this
-            number of seconds.
-        :param executor:
-            An executor, which file save operations are run in, to avoid
-            blocking the event loop. If not specified, we use a sensibly
-            configured :class:`ThreadPoolExecutor <concurrent.futures.ThreadPoolExecutor>`.
-        :param allowed_extensions:
-            Which file extensions are allowed. If ``None``, then all extensions
-            are allowed (not recommended unless the users are trusted).
-        :param allowed_characters:
-            Which characters are allowed in the file name. By default, it's
-            very strict. If set to ``None`` then all characters are allowed.
-        """  # noqa: E501
-
-        try:
-            import boto3  # noqa
-        except ImportError:  # pragma: no cover
-            sys.exit(
-                "Please install boto3 to use this feature "
-                "`pip install 'piccolo_admin[s3]'`"
-            )
-        else:
-            self.boto3 = boto3
-
-        self.bucket_name = bucket_name
-        self.folder_name = folder_name
-        self.connection_kwargs = connection_kwargs
-        self.signed_url_expiry = signed_url_expiry
-        self.executor = executor or ThreadPoolExecutor(max_workers=10)
-
-        super().__init__(
-            column=column,
-            allowed_extensions=allowed_extensions,
-            allowed_characters=allowed_characters,
-        )
-
-    def get_client(self):  # pragma: no cover
-        """
-        Returns an S3 clent.
-        """
-        session = self.boto3.session.Session()
-        client = session.client(
-            "s3",
-            **self.connection_kwargs,
-        )
-        return client
-
-    async def store_file(
-        self, file_name: str, file: t.IO, user: t.Optional[BaseUser] = None
-    ) -> str:
-        loop = asyncio.get_running_loop()
-
-        blocking_function = functools.partial(
-            self.store_file_sync, file_name=file_name, file=file, user=user
-        )
-
-        file_key = await loop.run_in_executor(self.executor, blocking_function)
-
-        return file_key
-
-    def store_file_sync(
-        self, file_name: str, file: t.IO, user: t.Optional[BaseUser] = None
-    ) -> str:
-        """
-        A sync wrapper around :meth:`store_file`.
-        """
-        file_key = self.generate_file_key(file_name=file_name, user=user)
-
-        client = self.get_client()
-
-        client.upload_fileobj(
-            file,
-            self.bucket_name,
-            str(pathlib.Path(self.folder_name, file_key)),
-        )
-
-        return file_key
-
-    async def generate_file_url(
-        self, file_key: str, root_url: str, user: t.Optional[BaseUser] = None
-    ) -> str:
-        """
-        This retrieves an absolute URL for the file.
-        """
-        loop = asyncio.get_running_loop()
-
-        blocking_function: t.Callable = functools.partial(
-            self.generate_file_url_sync,
-            file_key=file_key,
-            root_url=root_url,
-            user=user,
-        )
-
-        return await loop.run_in_executor(self.executor, blocking_function)
-
-    def generate_file_url_sync(
-        self, file_key: str, root_url: str, user: t.Optional[BaseUser] = None
-    ) -> str:
-        """
-        A sync wrapper around :meth:`generate_file_url`.
-        """
-        s3_client = self.get_client()
-
-        return s3_client.generate_presigned_url(
-            ClientMethod="get_object",
-            Params={
-                "Bucket": self.bucket_name,
-                "Key": str(pathlib.Path(self.folder_name, file_key)),
-            },
-            ExpiresIn=self.signed_url_expiry,
-        )
-
-    ###########################################################################
-
-    async def get_file(self, file_key: str) -> t.Optional[t.IO]:
-        """
-        Returns the file object matching the ``file_key``.
-        """
-        loop = asyncio.get_running_loop()
-
-        func = functools.partial(self.get_file_sync, file_key=file_key)
-
-        return await loop.run_in_executor(self.executor, func)
-
-    def get_file_sync(self, file_key: str) -> t.Optional[t.IO]:
-        """
-        Returns the file object matching the ``file_key``.
-        """
-        s3_client = self.get_client()
-        response = s3_client.get_object(
-            Bucket=self.bucket_name,
-            Key=str(pathlib.Path(self.folder_name, file_key)),
-        )
-        return response["Body"]
-
-    async def delete_file(self, file_key: str):
-        """
-        Deletes the file object matching the ``file_key``.
-        """
-        loop = asyncio.get_running_loop()
-
-        func = functools.partial(
-            self.delete_file_sync,
-            file_key=file_key,
-        )
-
-        return await loop.run_in_executor(self.executor, func)
-
-    def delete_file_sync(self, file_key: str):
-        """
-        Deletes the file object matching the ``file_key``.
-        """
-        s3_client = self.get_client()
-        return s3_client.delete_object(
-            Bucket=self.bucket_name,
-            Key=str(pathlib.Path(self.folder_name, file_key)),
-        )
-
-    async def bulk_delete_files(self, file_keys: t.List[str]):
-        loop = asyncio.get_running_loop()
-        func = functools.partial(
-            self.bulk_delete_files_sync,
-            file_keys=file_keys,
-        )
-        await loop.run_in_executor(self.executor, func)
-
-    def bulk_delete_files_sync(self, file_keys: t.List[str]):
-        s3_client = self.get_client()
-
-        batch_size = 100
-        iteration = 0
-        folder_name = self.folder_name
-
-        while True:
-            batch = file_keys[
-                (iteration * batch_size) : (  # noqa: E203
-                    iteration + 1 * batch_size
-                )
-            ]
-            if not batch:
-                # https://github.com/nedbat/coveragepy/issues/772
-                break  # pragma: no cover
-
-            s3_client.delete_objects(
-                Bucket=self.bucket_name,
-                Delete={
-                    "Objects": [
-                        {"Key": str(pathlib.Path(folder_name, file_key))}
-                        for file_key in file_keys
-                    ],
-                },
-            )
-
-            iteration += 1
-
-    def get_file_keys_sync(self) -> t.List[str]:
-        """
-        Returns the file key for each file we have stored.
-        """
-        s3_client = self.get_client()
-
-        keys = []
-        start_after = None
-
-        while True:
-            extra_kwargs: t.Dict[str, t.Any] = (
-                {"StartAfter": start_after} if start_after else {}
-            )
-
-            response = s3_client.list_objects_v2(
-                Bucket=self.bucket_name,
-                Prefix=self.folder_name,
-                **extra_kwargs,
-            )
-
-            contents = response.get("Contents")
-
-            if contents:
-                for obj in contents:
-                    keys.append(obj["Key"])
-
-                start_after = keys[-1]
-            else:
-                # https://github.com/nedbat/coveragepy/issues/772
-                break  # pragma: no cover
-
-        prefix = f"{self.folder_name}/"
-
-        return [i.lstrip(prefix) for i in keys]
-
-    async def get_file_keys(self) -> t.List[str]:
-        """
-        Returns the file key for each file we have stored.
-        """
-        loop = asyncio.get_running_loop()
-        return await loop.run_in_executor(
-            self.executor, self.get_file_keys_sync
-        )
-
-    def __hash__(self):
-        return hash(
-            (
-                "s3",
-                self.connection_kwargs.get("endpoint_url")
-                if self.connection_kwargs
-                else None,
-                self.bucket_name,
-                self.folder_name,
-            )
-        )
-
-    def __eq__(self, value):
-        if not isinstance(value, S3MediaStorage):
-            return False
-        return value.__hash__() == self.__hash__()
diff --git a/piccolo_admin/static/.gitkeep b/piccolo_admin/static/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/requirements/extra/s3.txt b/requirements/extra/s3.txt
deleted file mode 100644
index 7b622ee6..00000000
--- a/requirements/extra/s3.txt
+++ /dev/null
@@ -1,1 +0,0 @@
-boto3==1.*
diff --git a/requirements/extra/faker.txt b/requirements/extras/faker.txt
similarity index 100%
rename from requirements/extra/faker.txt
rename to requirements/extras/faker.txt
diff --git a/requirements/extras/s3.txt b/requirements/extras/s3.txt
new file mode 100644
index 00000000..372ab487
--- /dev/null
+++ b/requirements/extras/s3.txt
@@ -0,0 +1,1 @@
+piccolo_api[s3]
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
index 9992e035..d3102977 100644
--- a/requirements/requirements.txt
+++ b/requirements/requirements.txt
@@ -1,5 +1,5 @@
 piccolo>=0.58.0
-piccolo_api>=0.39.0
+piccolo_api>=0.41.0
 uvicorn
 aiofiles>=0.5.0
 Hypercorn


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index f3193c5d..ecbf4738 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -54,7 +54,6 @@ jobs:
               run: |
                   python -m pip install --upgrade pip
                   pip install -r requirements/requirements.txt
-                  pip install -r requirements/extra/s3.txt
                   pip install -r requirements/test-requirements.txt
             - name: Test with pytest, SQLite
               run: ./scripts/run-tests.sh
diff --git a/requirements/test-requirements.txt b/requirements/test-requirements.txt
index cb53c362..a0d3906d 100644
--- a/requirements/test-requirements.txt
+++ b/requirements/test-requirements.txt
@@ -1,6 +1,5 @@
 pytest==7.1.1
 pytest-cov==3.0.0
 flake8==4.0.1
-moto==3.1.16
 piccolo[postgres,sqlite]>=0.30.0
 requests==2.27.1
diff --git a/tests/media/test_base.py b/tests/media/test_base.py
deleted file mode 100644
index ad5b98ec..00000000
--- a/tests/media/test_base.py
+++ /dev/null
@@ -1,298 +0,0 @@
-import asyncio
-import os
-import shutil
-import tempfile
-import uuid
-from unittest import TestCase
-from unittest.mock import MagicMock, patch
-
-from piccolo.columns.column_types import Array, Integer
-from piccolo.table import create_db_tables_sync, drop_db_tables_sync
-
-from piccolo_admin.example import Director, Movie, Studio
-from piccolo_admin.media.local import LocalMediaStorage
-from piccolo_admin.media.s3 import S3MediaStorage
-
-
-class TestGenerateFileID(TestCase):
-    def setUp(self) -> None:
-        self.media_path = tempfile.gettempdir()
-        self.storage = LocalMediaStorage(
-            column=Movie.poster, media_path=self.media_path
-        )
-
-    def test_starts_with_period(self):
-        with self.assertRaises(ValueError) as manager:
-            self.storage.generate_file_key(file_name=".private_file.jpeg")
-
-        self.assertEqual(
-            str(manager.exception), "File names must not start with a period."
-        )
-
-    def test_double_period(self):
-        """
-        A file_name containing a double period shouldn't be allowed, as it
-        could potentially be used to traverse the file system.
-        """
-        with self.assertRaises(ValueError) as manager:
-            self.storage.generate_file_key(file_name="test..file.jpeg")
-
-        self.assertEqual(
-            str(manager.exception), "File names must not contain '..'."
-        )
-
-    def test_empty_file_name(self):
-        with self.assertRaises(ValueError) as manager:
-            self.storage.generate_file_key(file_name="")
-
-        self.assertEqual(
-            str(manager.exception), "The file name can't be empty."
-        )
-
-    def test_allowed_extensions(self):
-        with self.assertRaises(ValueError) as manager:
-            self.storage.generate_file_key(
-                file_name="test.abcdefghijklmonpqrstuvwxyz123"
-            )
-
-        self.assertEqual(
-            str(manager.exception), "This file type isn't allowed."
-        )
-
-    def test_allowed_characters(self):
-        with self.assertRaises(ValueError) as manager:
-            self.storage.generate_file_key(file_name="@{£}%^*jpeg")
-
-        self.assertEqual(
-            str(manager.exception), "'@' is not allowed in the filename."
-        )
-
-    def test_no_extension(self):
-        with self.assertRaises(ValueError) as manager:
-            self.storage.generate_file_key(file_name="test")
-
-        self.assertEqual(str(manager.exception), "The file has no extension.")
-
-    @patch("piccolo_admin.media.base.uuid")
-    def test_long_file_name(self, uuid_module: MagicMock):
-        """
-        Make sure that really long file names are truncated.
-        """
-        uuid_module.uuid4.return_value = uuid.UUID(
-            "fd0125c7-8777-4976-83c1-81605d5ab155"
-        )
-
-        truncated_file_key = self.storage.generate_file_key(
-            file_name="".join("a" for _ in range(200)) + ".jpg"
-        )
-        self.assertEqual(
-            truncated_file_key,
-            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-fd0125c7-8777-4976-83c1-81605d5ab155.jpg",  # noqa: E501
-        )
-
-
-class TestInit(TestCase):
-    def test_unsupported_column(self):
-        """
-        We only allow media columns which can store strings in them.
-        """
-        with self.assertRaises(ValueError) as manager:
-            LocalMediaStorage(column=Movie.rating, media_path="/tmp/")
-
-        self.assertEqual(
-            str(manager.exception),
-            "The column must be a `Varchar`, `Text`, or `Array`.",
-        )
-
-    def test_arrays(self):
-        """
-        Arrays are allowed, but only if the ``base_column`` can store strings.
-        """
-        with self.assertRaises(ValueError) as manager:
-            LocalMediaStorage(
-                column=Array(base_column=Integer()), media_path="/tmp/"
-            )
-
-        self.assertEqual(
-            str(manager.exception),
-            "The column must be a `Varchar`, `Text`, or `Array`.",
-        )
-
-        # This should raise no exceptions, as it's an Array of Varchar.
-        LocalMediaStorage(column=Movie.poster, media_path="/tmp/")
-
-
-class TestGetFileKeysFromDB(TestCase):
-    def setUp(self):
-        create_db_tables_sync(Movie, Director, Studio)
-
-    def tearDown(self):
-        drop_db_tables_sync(Movie, Director, Studio)
-
-    def test_get_file_keys_from_db(self):
-        Movie.insert(
-            Movie(poster="image-1.jpg"),
-            Movie(poster="image-2.jpg"),
-            Movie(poster="image-3.jpg"),
-        ).run_sync()
-
-        storage = LocalMediaStorage(column=Movie.poster, media_path="/tmp/")
-
-        response = asyncio.run(storage.get_file_keys_from_db())
-
-        self.assertListEqual(
-            sorted(response), ["image-1.jpg", "image-2.jpg", "image-3.jpg"]
-        )
-
-
-class TestDeleteUnusedFiles(TestCase):
-    def setUp(self):
-        create_db_tables_sync(Movie, Director, Studio)
-
-    def tearDown(self):
-        drop_db_tables_sync(Movie, Director, Studio)
-
-    def test_get_file_keys_from_db(self):
-        media_path = os.path.join(
-            tempfile.gettempdir(), "piccolo-admin-test-unused-files"
-        )
-
-        if os.path.exists(media_path):
-            shutil.rmtree(media_path)
-
-        os.mkdir(media_path)
-
-        file_names = ["image-1.jpg", "image-2.jpg", "image-3.jpg"]
-        extra_file_name = "image-4.jpg"
-
-        Movie.insert(
-            *[Movie(poster=file_name) for file_name in file_names]
-        ).run_sync()
-
-        for file_name in [*file_names, extra_file_name]:
-            with open(os.path.join(media_path, file_name), "wb") as _:
-                pass
-
-        storage = LocalMediaStorage(column=Movie.poster, media_path=media_path)
-
-        asyncio.run(storage.delete_unused_files(auto=True))
-
-        # The extra_file_name should have been deleted.
-        self.assertListEqual(sorted(os.listdir(media_path)), file_names)
-
-
-class TestHash(TestCase):
-    """
-    We want to be able to detect if multiple storage classes are
-    accidentally referencing the same folder.
-    """
-
-    def test_local_media(self):
-        """
-        Test comparing ``LocalMediaStorage``.
-        """
-        # These should be equal, as the media path is the same.
-        self.assertEqual(
-            LocalMediaStorage(column=Movie.poster, media_path="/tmp/"),
-            LocalMediaStorage(column=Movie.screenshots, media_path="/tmp/"),
-        )
-
-        # These shouldn't be equal, as the media paths are different.
-        self.assertNotEqual(
-            LocalMediaStorage(column=Movie.poster, media_path="/tmp/poster/"),
-            LocalMediaStorage(
-                column=Movie.screenshots, media_path="/tmp/screenshots/"
-            ),
-        )
-
-    def test_s3_media(self):
-        """
-        Test comparing ``S3MediaStorage``.
-        """
-        # These should be equal, as the folder name and bucket name as the
-        # same.
-        self.assertEqual(
-            S3MediaStorage(
-                column=Movie.poster,
-                bucket_name="bucker123",
-                folder_name="folder123",
-            ),
-            S3MediaStorage(
-                column=Movie.screenshots,
-                bucket_name="bucker123",
-                folder_name="folder123",
-            ),
-        )
-
-        # These shouldn't be equal, as the folder names are different.
-        self.assertNotEqual(
-            S3MediaStorage(
-                column=Movie.poster,
-                bucket_name="bucker123",
-                folder_name="folder123",
-            ),
-            S3MediaStorage(
-                column=Movie.screenshots,
-                bucket_name="bucker123",
-                folder_name="folder456",
-            ),
-        )
-
-        # These shouldn't be equal, as the bucket names are different.
-        self.assertNotEqual(
-            S3MediaStorage(
-                column=Movie.poster,
-                bucket_name="bucker123",
-                folder_name="folder123",
-            ),
-            S3MediaStorage(
-                column=Movie.screenshots,
-                bucket_name="bucker456",
-                folder_name="folder123",
-            ),
-        )
-
-        # These shouldn't be equal, as the endpoint URLs are different.
-        self.assertNotEqual(
-            S3MediaStorage(
-                column=Movie.poster,
-                connection_kwargs={"endpoint_url": "https://cloud-1.com"},
-                bucket_name="bucker123",
-                folder_name="folder123",
-            ),
-            S3MediaStorage(
-                column=Movie.screenshots,
-                connection_kwargs={"endpoint_url": "https://cloud-2.com"},
-                bucket_name="bucker123",
-                folder_name="folder123",
-            ),
-        )
-
-    def test_mix(self):
-        """
-        Test comparing a mix of ``LocalMediaStorage`` and ``S3MediaStorage``.
-        """
-        self.assertNotEqual(
-            LocalMediaStorage(column=Movie.poster, media_path="/tmp/"),
-            S3MediaStorage(
-                column=Movie.screenshots,
-                bucket_name="bucker123",
-                folder_name="folder456",
-            ),
-        )
-
-    def test_sets(self):
-        """
-        Make sure sets behave as expected.
-        """
-        self.assertEqual(
-            len(
-                {
-                    LocalMediaStorage(column=Movie.poster, media_path="/tmp/"),
-                    LocalMediaStorage(
-                        column=Movie.screenshots, media_path="/tmp/"
-                    ),
-                },
-            ),
-            1,
-        )
diff --git a/tests/media/test_files/bulb.jpg b/tests/media/test_files/bulb.jpg
deleted file mode 100644
index a9a82887..00000000
Binary files a/tests/media/test_files/bulb.jpg and /dev/null differ
diff --git a/tests/media/test_local.py b/tests/media/test_local.py
deleted file mode 100644
index f3377b54..00000000
--- a/tests/media/test_local.py
+++ /dev/null
@@ -1,101 +0,0 @@
-import asyncio
-import os
-import shutil
-import tempfile
-import uuid
-from unittest import TestCase
-from unittest.mock import MagicMock, patch
-
-from piccolo_admin.example import Movie
-from piccolo_admin.media.local import LocalMediaStorage
-
-
-class TestLocalMediaStorage(TestCase):
-    def test_folder_created(self):
-        """
-        If the media folder doesn't exist, then ``LocalMediaStorage`` should
-        try and create it.
-        """
-        media_path = os.path.join(tempfile.gettempdir(), "randomfolder")
-
-        if os.path.exists(media_path):
-            shutil.rmtree(media_path)
-
-        LocalMediaStorage(column=Movie.poster, media_path=media_path)
-
-        self.assertTrue(os.path.exists(media_path))
-
-    @patch("piccolo_admin.media.base.uuid")
-    def test_store_file(self, uuid_module: MagicMock):
-        """
-        Make sure we can store files, and retrieve them.
-        """
-        uuid_module.uuid4.return_value = uuid.UUID(
-            "fd0125c7-8777-4976-83c1-81605d5ab155"
-        )
-
-        media_path = os.path.join(tempfile.gettempdir(), "piccolo-admin-media")
-
-        if os.path.exists(media_path):
-            shutil.rmtree(media_path)
-
-        os.mkdir(media_path)
-
-        storage = LocalMediaStorage(column=Movie.poster, media_path=media_path)
-
-        with open(
-            os.path.join(os.path.dirname(__file__), "test_files/bulb.jpg"),
-            "rb",
-        ) as test_file:
-            # Store the file
-            file_key = storage.store_file_sync(
-                file_name="bulb.jpg", file=test_file
-            )
-
-            # Make sure the file was stored.
-            self.assertTrue(file_key in os.listdir(media_path))
-
-            # Make sure the permissions are correct
-            self.assertEqual(
-                oct(os.stat(os.path.join(media_path, file_key)).st_mode)[-3:],
-                "640",
-            )
-
-            # Retrieve the URL for the file
-            url = storage.generate_file_url_sync(file_key, root_url="/media/")
-            self.assertEqual(
-                url, "/media/bulb-fd0125c7-8777-4976-83c1-81605d5ab155.jpg"
-            )
-
-            # Retrieve the file itself
-            file = asyncio.run(storage.get_file(file_key=file_key))
-            assert file is not None
-            test_file.seek(0, 0)
-            self.assertEqual(file.read(), test_file.read())
-            file.close()
-
-            # Make sure that a value is raised if we try saving a file with the
-            # same name.
-            with self.assertRaises(IOError):
-                storage.store_file_sync(file_name="bulb.jpg", file=test_file)
-
-            # List all of the files
-            file_list = asyncio.run(storage.get_file_keys())
-            self.assertEqual(file_list, [file_key])
-
-            # Delete the file
-            asyncio.run(storage.delete_file(file_key=file_key))
-            self.assertEqual(os.listdir(storage.media_path), [])
-
-            # Test bulk deletion
-            for file_name in ("file_1.txt", "file_2.txt", "file_3.txt"):
-                with open(os.path.join(media_path, file_name), "w") as f:
-                    f.write("test")
-
-            asyncio.run(
-                storage.bulk_delete_files(
-                    file_keys=["file_1.txt", "file_2.txt"]
-                )
-            )
-
-            self.assertListEqual(os.listdir(media_path), ["file_3.txt"])
diff --git a/tests/media/test_s3.py b/tests/media/test_s3.py
deleted file mode 100644
index 632b7c43..00000000
--- a/tests/media/test_s3.py
+++ /dev/null
@@ -1,112 +0,0 @@
-import asyncio
-import io
-import os
-import uuid
-from unittest import TestCase
-from unittest.mock import MagicMock, patch
-
-import boto3
-from moto import mock_s3
-
-from piccolo_admin.example import Movie
-from piccolo_admin.media.s3 import S3MediaStorage
-
-
-class TestS3MediaStorage(TestCase):
-    @patch("piccolo_admin.media.base.uuid")
-    @patch("piccolo_admin.media.s3.S3MediaStorage.get_client")
-    def test_store_file(self, get_client: MagicMock, uuid_module: MagicMock):
-        """
-        Make sure we can store files, and retrieve them.
-        """
-        uuid_module.uuid4.return_value = uuid.UUID(
-            "fd0125c7-8777-4976-83c1-81605d5ab155"
-        )
-        bucket_name = "bucket123"
-        folder_name = "movie_posters"
-
-        with mock_s3():
-            s3 = boto3.resource("s3", region_name="us-east-1")
-            s3.create_bucket(Bucket=bucket_name)
-
-            connection_kwargs = {
-                "aws_access_key_id": "abc123",
-                "aws_secret_access_key": "xyz123",
-                "region_name": "us-east-1",
-            }
-
-            get_client.return_value = boto3.client("s3", **connection_kwargs)
-
-            storage = S3MediaStorage(
-                column=Movie.poster,
-                bucket_name=bucket_name,
-                folder_name=folder_name,
-                connection_kwargs=connection_kwargs,
-            )
-
-            with open(
-                os.path.join(os.path.dirname(__file__), "test_files/bulb.jpg"),
-                "rb",
-            ) as test_file:
-                # Store the file
-                file_key = asyncio.run(
-                    storage.store_file(file_name="bulb.jpg", file=test_file)
-                )
-
-                # Retrieve the URL for the file
-                url = asyncio.run(
-                    storage.generate_file_url(file_key, root_url="")
-                )
-
-                path, params = url.split("?", 1)
-
-                self.assertEqual(
-                    path,
-                    f"https://{bucket_name}.s3.amazonaws.com/{folder_name}/{file_key}",  # noqa: E501
-                )
-
-                # We're parsing a string like this:
-                # AWSAccessKeyId=abc123&Signature=abc123&Expires=1659437428
-                params_list = [i.split("=") for i in params.split("&")]
-
-                params_dict = {i[0]: i[1] for i in params_list}
-
-                self.assertEqual(
-                    params_dict["AWSAccessKeyId"],
-                    connection_kwargs["aws_access_key_id"],
-                )
-                self.assertTrue("Signature" in params_dict)
-                self.assertTrue("Expires" in params_dict)
-
-                # Get the file
-                file = asyncio.run(storage.get_file(file_key=file_key))
-                assert file is not None
-                self.assertEqual(
-                    file.read(),
-                    # We need to reopen the test file, in case it's closed:
-                    open(test_file.name, "rb").read(),
-                )
-
-                # List file keys
-                file_keys = asyncio.run(storage.get_file_keys())
-                self.assertListEqual(file_keys, [file_key])
-
-                # Delete the file
-                asyncio.run(storage.delete_file(file_key=file_key))
-                file_keys = asyncio.run(storage.get_file_keys())
-                self.assertListEqual(file_keys, [])
-
-                # Test bulk deletion
-                file_keys = []
-                for file_name in ("file_1.txt", "file_2.txt", "file_3.txt"):
-                    file = io.BytesIO(b"test")
-                    file_key = asyncio.run(
-                        storage.store_file(file_name=file_name, file=file)
-                    )
-                    file_keys.append(file_key)
-
-                asyncio.run(storage.bulk_delete_files(file_keys=file_keys[:2]))
-
-                self.assertListEqual(
-                    asyncio.run(storage.get_file_keys()), file_keys[2:]
-                )
diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py
index 3454fcfb..64066438 100644
--- a/tests/test_endpoints.py
+++ b/tests/test_endpoints.py
@@ -20,7 +20,7 @@
 
 from piccolo_admin.endpoints import TableConfig, create_admin, get_all_tables
 from piccolo_admin.example import APP, MEDIA_ROOT, Director, Movie
-from piccolo_admin.media.local import LocalMediaStorage
+from piccolo_api.media.local import LocalMediaStorage
 from piccolo_admin.translations.data import ENGLISH, FRENCH, TRANSLATIONS
 from piccolo_admin.version import __VERSION__
 
@@ -359,7 +359,7 @@ def setUp(self):
     def tearDown(self):
         drop_db_tables_sync(SessionsBase, BaseUser)
 
-    @patch("piccolo_admin.media.base.uuid")
+    @patch("piccolo_api.media.base.uuid")
     def test_image_upload(self, uuid_module: MagicMock):
         uuid_value = uuid.uuid4()
         uuid_module.uuid4.return_value = uuid_value
@@ -379,7 +379,7 @@ def test_image_upload(self, uuid_module: MagicMock):
         )
 
         test_file_path = os.path.join(
-            os.path.dirname(__file__), "media/test_files/bulb.jpg"
+            os.path.dirname(__file__), "files/bulb.jpg"
         )
 
         with open(test_file_path, "rb") as test_file: