Skip to content

Commit

Permalink
add limit, offset, filter, sorting and search to file_list and album_…
Browse files Browse the repository at this point in the history
…list APIs, write more API tests
  • Loading branch information
tykling committed Oct 7, 2022
1 parent 902cf09 commit ad32777
Show file tree
Hide file tree
Showing 12 changed files with 345 additions and 33 deletions.
4 changes: 2 additions & 2 deletions .flake8
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[flake8]
max-line-length = 88
ignore = E501 W503 E231
max-line-length = 120
ignore = E501 W503 E231 E203
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ repos:
- flake8-bugbear
- flake8-comprehensions
- flake8-tidy-imports
args: [--max-line-length=120]
- repo: https://github.com/rtts/djhtml
rev: 'v1.5.2' # replace with the latest tag on GitHub
hooks:
Expand Down
38 changes: 36 additions & 2 deletions src/albums/api.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import logging
import operator
import uuid
from functools import reduce
from typing import List

from django.db.models import Q
from django.shortcuts import get_object_or_404
from ninja import Query
from ninja import Router

from .models import Album
from .schema import AlbumFilters
from .schema import AlbumInSchema
from .schema import AlbumOutSchema
from utils.schema import MessageSchema
Expand All @@ -15,6 +20,9 @@
# initialise API router
router = Router()

# https://django-ninja.rest-framework.com/guides/input/query-params/#using-schema
query = Query(...)


@router.post(
"/albums/",
Expand Down Expand Up @@ -49,9 +57,35 @@ def album_get(request, album_uuid: uuid.UUID):
response={200: List[AlbumOutSchema]},
summary="Return a list of albums.",
)
def album_list(request):
def album_list(request, filters: AlbumFilters = query):
"""Return a list of albums."""
return Album.objects.all()
albums = Album.objects.all()

if filters.files:
# __in is OR and we want AND, build a query for .exclude() with all file UUIDs
query = reduce(operator.and_, (Q(files__uuid=uuid) for uuid in filters.files))
albums = albums.exclude(~query)

if filters.search:
albums = albums.filter(title__icontains=filters.search) | albums.filter(
description__icontains=filters.search,
)

if filters.sorting:
if filters.sorting.endswith("_asc"):
# remove _asc and add +
albums = albums.order_by(f"{filters.sorting[:-4]}")
else:
# remove _desc and add -
albums = albums.order_by(f"-{filters.sorting[:-5]}")

if filters.offset:
albums = albums[filters.offset :]

if filters.limit:
albums = albums[: filters.limit]

return albums


@router.put(
Expand Down
9 changes: 9 additions & 0 deletions src/albums/schema.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import uuid
from typing import List

from ninja import Field
from ninja import ModelSchema

from albums.models import Album
from utils.schema import ListFilters


class AlbumInSchema(ModelSchema):
Expand All @@ -23,3 +26,9 @@ class AlbumOutSchema(ModelSchema):
class Config:
model = Album
model_fields = "__all__"


class AlbumFilters(ListFilters):
"""The filters used for the album_list endpoint."""

files: List[uuid.UUID] = Field(None, alias="files")
90 changes: 86 additions & 4 deletions src/albums/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@
class TestAlbumsApi(ApiTestBase):
"""Test for API endpoints in the albums API."""

def test_album_create(self, files=None):
def test_album_create(
self,
title="album title here",
description="album description here",
files=None,
):
"""Test creating an album."""
response = self.client.post(
reverse("api-v1-json:album_create"),
{
"title": "album title here",
"title": title,
"description": description,
"files": files if files else [],
},
HTTP_AUTHORIZATION=self.auth,
Expand All @@ -27,12 +33,16 @@ def test_album_create(self, files=None):
assert response.status_code == 201
self.album_uuid = response.json()["uuid"]

def test_album_create_with_files(self):
def test_album_create_with_files(
self,
title="album title here",
description="album description here",
):
"""Test creating an album with files."""
self.files = []
for _ in range(10):
self.files.append(self.file_upload())
self.test_album_create(self.files)
self.test_album_create(title=title, description=description, files=self.files)

def test_album_update(self):
"""First replace then update."""
Expand Down Expand Up @@ -89,8 +99,80 @@ def test_album_delete(self):
reverse("api-v1-json:album_get", kwargs={"album_uuid": self.album_uuid}),
)
assert response.status_code == 403
response = self.client.delete(
reverse("api-v1-json:album_get", kwargs={"album_uuid": self.album_uuid}),
HTTP_AUTHORIZATION=f"Bearer {self.user2.tokeninfo['access_token']}",
)
assert response.status_code == 403
response = self.client.delete(
reverse("api-v1-json:album_get", kwargs={"album_uuid": self.album_uuid}),
HTTP_AUTHORIZATION=self.auth,
)
assert response.status_code == 204

def test_album_get(self):
"""Get album metadata from the API."""
self.test_album_create_with_files()
response = self.client.get(
reverse("api-v1-json:album_get", kwargs={"album_uuid": self.album_uuid}),
HTTP_AUTHORIZATION=self.auth,
)
assert response.status_code == 200

def test_album_list(self):
"""Get album list from the API."""
for i in range(10):
self.test_album_create_with_files(title=f"album{i}")
response = self.client.get(
reverse("api-v1-json:album_list"),
HTTP_AUTHORIZATION=self.auth,
)
assert response.status_code == 200
assert len(response.json()) == 10

# test the file filter with files in different albums
response = self.client.get(
reverse("api-v1-json:album_list"),
data={"files": [self.files[0], response.json()[1]["files"][0]]},
HTTP_AUTHORIZATION=self.auth,
)
assert response.status_code == 200
print(response.json())
assert len(response.json()) == 0
# test with files in the same album
response = self.client.get(
reverse("api-v1-json:album_list"),
data={"files": [self.files[0], self.files[1]]},
HTTP_AUTHORIZATION=self.auth,
)
assert response.status_code == 200
assert len(response.json()) == 1

# test search
response = self.client.get(
reverse("api-v1-json:album_list"),
data={"search": "album4"},
HTTP_AUTHORIZATION=self.auth,
)
assert response.status_code == 200
assert len(response.json()) == 1

# test sorting
response = self.client.get(
reverse("api-v1-json:album_list"),
data={"sorting": "created_desc"},
HTTP_AUTHORIZATION=self.auth,
)
assert response.status_code == 200
assert len(response.json()) == 10
assert response.json()[0]["title"] == "album9"

# test offset
response = self.client.get(
reverse("api-v1-json:album_list"),
data={"sorting": "title_asc", "offset": 5},
HTTP_AUTHORIZATION=self.auth,
)
assert response.status_code == 200
assert len(response.json()) == 5
assert response.json()[0]["title"] == "album5"
1 change: 1 addition & 0 deletions src/bma/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sites",
"django.contrib.postgres",
# deps
"allauth",
"allauth.account",
Expand Down
47 changes: 35 additions & 12 deletions src/files/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@
import magic
from django.conf import settings
from django.shortcuts import get_object_or_404
from ninja import Query
from ninja import Router
from ninja.files import UploadedFile

from .models import BaseFile
from .schema import FileFilters
from .schema import FileOutSchema
from .schema import FileUpdateSchema
from .schema import UploadMetadata
from audios.schema import AudioOutSchema
from documents.schema import DocumentOutSchema
from pictures.schema import PictureOutSchema
from utils.license import LicenseChoices
from utils.schema import MessageSchema
from videos.schema import VideoOutSchema

Expand All @@ -25,6 +26,9 @@
# initialise API router
router = Router()

# https://django-ninja.rest-framework.com/guides/input/query-params/#using-schema
query = Query(...)


@router.post(
"/upload/",
Expand Down Expand Up @@ -63,10 +67,6 @@ def upload(request, f: UploadedFile, metadata: UploadMetadata):
# title defaults to the original filename
uploaded_file.title = uploaded_file.original_filename

# XXX why doesn't django-ninja validate enums?
if uploaded_file.license not in LicenseChoices:
return 422, {"message": "Invalid license"}

# save everything and return
uploaded_file.save()
return 201, uploaded_file
Expand Down Expand Up @@ -95,15 +95,37 @@ def file_get(request, file_uuid: uuid.UUID):
response={200: List[FileOutSchema]},
summary="Return a list of all files.",
)
def file_list(request):
"""Return a list of all files."""
if request.user.is_superuser:
return BaseFile.objects.all()
else:
return BaseFile.objects.filter(status="PUBLISHED") | BaseFile.objects.filter(
owner=request.user,
def file_list(request, filters: FileFilters = query):
"""Return a list of all files. Supports offset, limit, query."""
files = BaseFile.objects.all()

if not request.user.is_superuser:
files = files.filter(status="PUBLISHED") | files.filter(owner=request.user)

if filters.albums:
files = files.filter(albums__in=filters.albums)

if filters.search:
files = files.filter(title__icontains=filters.search) | files.filter(
description__icontains=filters.search,
)

if filters.sorting:
if filters.sorting.endswith("_asc"):
# remove _asc and add +
files = files.order_by(f"{filters.sorting[:-4]}")
else:
# remove _desc and add -
files = files.order_by(f"-{filters.sorting[:-5]}")

if filters.offset:
files = files[filters.offset :]

if filters.limit:
files = files[: filters.limit]

return files


@router.put(
"/{file_uuid}/",
Expand Down Expand Up @@ -155,6 +177,7 @@ def file_delete(request, file_uuid: uuid.UUID):
if basefile.owner != request.user:
return 403, {"message": f"No permission to delete file {file_uuid}"}
# we don't let users fully delete files for now
# basefile.delete()
basefile.status = "PENDING_DELETION"
basefile.save()
return 204, None
11 changes: 11 additions & 0 deletions src/files/schema.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import uuid
from pathlib import Path
from typing import List
from typing import Optional

from ninja import Field
from ninja import ModelSchema

from albums.schema import AlbumOutSchema
from files.models import BaseFile
from utils.license import LicenseChoices
from utils.schema import ListFilters
from utils.schema import SortingChoices


class UploadMetadata(ModelSchema):
Expand Down Expand Up @@ -49,3 +53,10 @@ class FileUpdateSchema(ModelSchema):
class Config:
model = BaseFile
model_fields = ["title", "description", "source", "license", "attribution"]


class FileFilters(ListFilters):
"""The filters used for the file_list endpoint."""

sorting: SortingChoices = None
albums: List[uuid.UUID] = Field(None, alias="albums")
Loading

0 comments on commit ad32777

Please sign in to comment.