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

Add support for package upload from frontend to API #268

Merged
merged 24 commits into from
Mar 30, 2021
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ SECRET_KEY=xxx
ALLOWED_HOSTS=*
DATABASE_LOGS=0
DATABASE_QUERY_COUNT_HEADER=1
CORS_ALLOWED_ORIGINS=*

SOCIAL_AUTH_DISCORD_KEY=
SOCIAL_AUTH_DISCORD_SECRET=
Expand Down
436 changes: 235 additions & 201 deletions django/poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions django/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ django-celery-results = "^2.0.0"
django-celery-beat = "^2.1.0"
ulid2 = "^0.2.0"
django-cachalot = "^2.3.3"
django-cors-headers = "^3.7.0"

[tool.poetry.dev-dependencies]
pytest = "^6.2"
Expand Down
32 changes: 32 additions & 0 deletions django/thunderstore/account/authentication.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from django.contrib.auth import SESSION_KEY, get_user_model
from django.contrib.sessions.backends.db import SessionStore
from django.utils import timezone
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication as DRFTokenAuthentication
from rest_framework.authtoken.models import Token

from thunderstore.account.models import ServiceAccount

User = get_user_model()


class TokenAuthentication(DRFTokenAuthentication):
keyword = "Bearer"
Expand All @@ -18,3 +23,30 @@ def authenticate(self, request):
service_account.last_used = timezone.now()
service_account.save(update_fields=("last_used",))
return out


class UserSessionTokenAuthentication(DRFTokenAuthentication):
"""
This authentication is used for the Django React transition only.

This uses the session ID prepended by "Session ". For example:
`Authorization: Session cu5zafrhapnck64nsyz7cl9w6ezwuuz4`
"""

keyword = "Session"

def authenticate_credentials(self, key):
session = SessionStore(session_key=key)
if not session.exists(key):
raise exceptions.AuthenticationFailed("Invalid token.")
user_id = session.get(SESSION_KEY)
if user_id is None:
raise exceptions.AuthenticationFailed("Invalid token.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
raise exceptions.AuthenticationFailed("User inactive or deleted.")
if not user.is_active:
raise exceptions.AuthenticationFailed("User inactive or deleted.")

return (user, None)
Empty file.
36 changes: 36 additions & 0 deletions django/thunderstore/account/tests/test_user_session_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import pytest
import rest_framework.exceptions

from thunderstore.account.authentication import UserSessionTokenAuthentication


@pytest.mark.django_db
def test_user_session_token(client, user):
client.force_login(user)
session_id = client.cookies["sessionid"].value
authenticated_user, _ = UserSessionTokenAuthentication().authenticate_credentials(
session_id,
)
assert authenticated_user == user


@pytest.mark.django_db
def test_user_session_token_invalid():
with pytest.raises(
rest_framework.exceptions.AuthenticationFailed,
match="Invalid token.",
):
UserSessionTokenAuthentication().authenticate_credentials("INVALID_SESSION_ID")


@pytest.mark.django_db
def test_user_session_token_inactive_user(client, user):
user.is_active = False
user.save()
client.force_login(user)
session_id = client.cookies["sessionid"].value
with pytest.raises(
rest_framework.exceptions.AuthenticationFailed,
match="User inactive or deleted.",
):
UserSessionTokenAuthentication().authenticate_credentials(session_id)
Empty file.
23 changes: 23 additions & 0 deletions django/thunderstore/community/api/experimental/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from rest_framework import serializers

from thunderstore.community.models import Community, PackageCategory


class CommunitySerializer(serializers.ModelSerializer):
class Meta:
model = Community
fields = (
"identifier",
"name",
"discord_url",
"wiki_url",
)


class PackageCategorySerializer(serializers.ModelSerializer):
class Meta:
model = PackageCategory
fields = (
"name",
"slug",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import pytest

from thunderstore.community.models import Community


@pytest.mark.django_db
def test_api_experimental_categories_list(
api_client,
community,
):
response = api_client.get(
f"/api/experimental/community/{community.identifier}/category/",
HTTP_ACCEPT="application/json",
)
assert response.status_code == 200
assert isinstance(response.json()["packageCategories"], list)


@pytest.mark.django_db
def test_api_experimental_categories_list_not_found(
api_client,
):
invalid_community_identifier = "NOT_A_COMMUNITY_IDENTIFIER"
assert not Community.objects.filter(
identifier=invalid_community_identifier,
).exists()
response = api_client.get(
f"/api/experimental/community/{invalid_community_identifier}/category/",
HTTP_ACCEPT="application/json",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import pytest


@pytest.mark.django_db
def test_api_experimental_communities_list(
api_client,
):
response = api_client.get(
"/api/experimental/community/",
HTTP_ACCEPT="application/json",
)
assert response.status_code == 200
assert isinstance(response.json()["communities"], list)
19 changes: 19 additions & 0 deletions django/thunderstore/community/api/experimental/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.urls import path

from thunderstore.community.api.experimental.views import (
CommunitiesExperimentalApiView,
PackageCategoriesExperimentalApiView,
)

urls = [
path(
"community/",
CommunitiesExperimentalApiView.as_view(),
name="communities",
),
path(
"community/<slug:community>/category/",
PackageCategoriesExperimentalApiView.as_view(),
name="categories",
),
]
90 changes: 90 additions & 0 deletions django/thunderstore/community/api/experimental/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from collections import OrderedDict

from rest_framework.generics import ListAPIView, get_object_or_404
from rest_framework.pagination import CursorPagination
from rest_framework.response import Response

from thunderstore.community.api.experimental.serializers import (
CommunitySerializer,
PackageCategorySerializer,
)
from thunderstore.community.models import Community


class CustomCursorPagination(CursorPagination):
ordering = "-datetime_created"
results_name = "results"
page_size = 100

def get_paginated_response(self, data) -> Response:
nihaals marked this conversation as resolved.
Show resolved Hide resolved
return Response(
OrderedDict(
[
(
"pagination",
OrderedDict(
[
("next_link", self.get_next_link()),
("previous_link", self.get_previous_link()),
nihaals marked this conversation as resolved.
Show resolved Hide resolved
],
),
),
(self.results_name, data),
],
),
)

def get_unpaginated_response(self, data) -> Response:
return Response(
Response(
OrderedDict(
[
("pagination", {}),
(self.results_name, data),
],
),
),
)


class CustomListAPIView(ListAPIView):
pagination_class = CustomCursorPagination
paginator: CustomCursorPagination

def get_unpaginated_response(self, data):
return self.paginator.get_unpaginated_response(data)

def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())

page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)

serializer = self.get_serializer(queryset, many=True)
return self.get_unpaginated_response(serializer.data)


class CommunitiesPagination(CustomCursorPagination):
results_name = "communities"


class CommunitiesExperimentalApiView(CustomListAPIView):
pagination_class = CommunitiesPagination
queryset = Community.objects.listed()
serializer_class = CommunitySerializer


class PackageCategoriesPagination(CustomCursorPagination):
results_name = "packageCategories"


class PackageCategoriesExperimentalApiView(CustomListAPIView):
pagination_class = PackageCategoriesPagination
serializer_class = PackageCategorySerializer

def get_queryset(self):
community_identifier = self.kwargs.get("community")
community = get_object_or_404(Community, identifier=community_identifier)
return community.package_categories
16 changes: 14 additions & 2 deletions django/thunderstore/core/api_urls.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
from django.urls import include, path

from thunderstore.repository.api.experimental.urls import urls as experimental_urls
from thunderstore.community.api.experimental.urls import (
urls as community_experimental_urls,
)
from thunderstore.repository.api.experimental.urls import (
urls as repository_experimental_urls,
)
from thunderstore.repository.api.v1.urls import urls as v1_urls

api_experimental_urls = [
path(
"",
include((experimental_urls, "api-experimental"), namespace="api-experimental"),
include(
(repository_experimental_urls, "api-experimental"),
namespace="api-experimental",
),
),
path(
"",
include(community_experimental_urls),
),
]
Comment on lines 11 to 23
Copy link
Member Author

Choose a reason for hiding this comment

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

This could be improved


Expand Down
13 changes: 13 additions & 0 deletions django/thunderstore/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
DISABLE_SERVER_SIDE_CURSORS=(bool, True),
SECRET_KEY=(str, ""),
ALLOWED_HOSTS=(list, []),
CORS_ALLOWED_ORIGINS=(list, []),
PROTOCOL=(str, ""),
SOCIAL_AUTH_DISCORD_KEY=(str, ""),
SOCIAL_AUTH_DISCORD_SECRET=(str, ""),
Expand Down Expand Up @@ -83,6 +84,10 @@
SECRET_KEY = env.str("SECRET_KEY")

ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")
CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS")
MythicManiac marked this conversation as resolved.
Show resolved Hide resolved
if CORS_ALLOWED_ORIGINS == ["*"]:
CORS_ALLOWED_ORIGINS = []
CORS_ALLOW_ALL_ORIGINS = True

DATABASE_LOGS = env.bool("DATABASE_LOGS")
DATABASE_QUERY_COUNT_HEADER = env.bool("DATABASE_QUERY_COUNT_HEADER")
Expand Down Expand Up @@ -155,6 +160,7 @@ def load_db_certs():
"django_celery_beat",
"django_celery_results",
"cachalot",
"corsheaders",
# Own
"thunderstore.core",
"thunderstore.cache",
Expand All @@ -172,6 +178,7 @@ def load_db_certs():
"django.middleware.security.SecurityMiddleware",
"thunderstore.frontend.middleware.SocialAuthExceptionHandlerMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
Expand Down Expand Up @@ -251,6 +258,11 @@ def load_db_certs():

USE_TZ = True

# Sessions

# Session cookie used by React components during Django React transition
SESSION_COOKIE_HTTPONLY = False
MythicManiac marked this conversation as resolved.
Show resolved Hide resolved

# Celery

CELERY_BROKER_URL = env.str("CELERY_BROKER_URL")
Expand Down Expand Up @@ -409,6 +421,7 @@ def show_debug_toolbar(request):
"DEFAULT_PARSER_CLASSES": ["rest_framework.parsers.JSONParser"],
"DEFAULT_AUTHENTICATION_CLASSES": [
"thunderstore.account.authentication.TokenAuthentication",
"thunderstore.account.authentication.UserSessionTokenAuthentication",
],
}

Expand Down
13 changes: 12 additions & 1 deletion django/thunderstore/social/api/experimental/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,22 @@ class CurrentUserExperimentalApiView(APIView):
"""

def get(self, request, format=None):
username = None
capabilities = set()
rated_packages = []
teams = []
if request.user.is_authenticated:
username = request.user.username
capabilities.add("package.rate")
rated_packages = request.user.package_ratings.select_related(
"package"
).values_list("package__uuid4", flat=True)
return Response({"capabilities": capabilities, "ratedPackages": rated_packages})
teams = request.user.uploader_identities.values_list("identity__name")
return Response(
{
"username": username,
"capabilities": capabilities,
"ratedPackages": rated_packages,
"teams": teams,
},
)