♻️(backend) refactor post hackathon to a first working version
This project was copied and hacked to make a POC in a 2-day hackathon. We need to clean and refactor things in order to get a first version of the product we want.
This commit is contained in:
committed by
Samuel Paccoud
parent
0a3e26486e
commit
0f9327a1de
@@ -11,7 +11,16 @@ class IsAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return bool(request.auth) if request.auth else request.user.is_authenticated
|
||||
return bool(request.auth) or request.user.is_authenticated
|
||||
|
||||
|
||||
class IsAuthenticatedOrSafe(IsAuthenticated):
|
||||
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return super().has_permission(request, view)
|
||||
|
||||
|
||||
class IsSelf(IsAuthenticated):
|
||||
@@ -45,10 +54,10 @@ class IsOwnedOrPublic(IsAuthenticated):
|
||||
return False
|
||||
|
||||
|
||||
class AccessPermission(IsAuthenticated):
|
||||
class AccessPermission(permissions.BasePermission):
|
||||
"""Permission class for access objects."""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check permission for a given object."""
|
||||
abilities = obj.get_abilities(request.user)
|
||||
return abilities.get(request.method.lower(), False)
|
||||
return abilities.get(view.action, False)
|
||||
|
||||
@@ -1,31 +1,12 @@
|
||||
"""Client serializers for the publish core app."""
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import exceptions, serializers
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class ContactSerializer(serializers.ModelSerializer):
|
||||
"""Serialize contacts."""
|
||||
|
||||
class Meta:
|
||||
model = models.Contact
|
||||
fields = [
|
||||
"id",
|
||||
"base",
|
||||
"data",
|
||||
"full_name",
|
||||
"owner",
|
||||
"short_name",
|
||||
]
|
||||
read_only_fields = ["id", "owner"]
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Make "base" field readonly but only for update/patch."""
|
||||
validated_data.pop("base", None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
@@ -42,13 +23,14 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = ["id", "is_device", "is_staff"]
|
||||
|
||||
class TeamAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize team accesses."""
|
||||
|
||||
class TemplateAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize template accesses."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.TeamAccess
|
||||
model = models.TemplateAccess
|
||||
fields = ["id", "user", "role", "abilities"]
|
||||
read_only_fields = ["id", "abilities"]
|
||||
|
||||
@@ -80,58 +62,65 @@ class TeamAccessSerializer(serializers.ModelSerializer):
|
||||
message = (
|
||||
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
|
||||
if can_set_role_to
|
||||
else "You are not allowed to set this role for this team."
|
||||
else "You are not allowed to set this role for this template."
|
||||
)
|
||||
raise exceptions.PermissionDenied(message)
|
||||
|
||||
# Create
|
||||
else:
|
||||
try:
|
||||
team_id = self.context["team_id"]
|
||||
template_id = self.context["template_id"]
|
||||
except KeyError as exc:
|
||||
raise exceptions.ValidationError(
|
||||
"You must set a team ID in kwargs to create a new team access."
|
||||
"You must set a template ID in kwargs to create a new template access."
|
||||
) from exc
|
||||
|
||||
if not models.TeamAccess.objects.filter(
|
||||
team=team_id,
|
||||
if not models.TemplateAccess.objects.filter(
|
||||
template=template_id,
|
||||
user=user,
|
||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
||||
).exists():
|
||||
raise exceptions.PermissionDenied(
|
||||
"You are not allowed to manage accesses for this team."
|
||||
"You are not allowed to manage accesses for this template."
|
||||
)
|
||||
|
||||
if (
|
||||
role == models.RoleChoices.OWNER
|
||||
and not models.TeamAccess.objects.filter(
|
||||
team=team_id,
|
||||
and not models.TemplateAccess.objects.filter(
|
||||
template=template_id,
|
||||
user=user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
).exists()
|
||||
):
|
||||
raise exceptions.PermissionDenied(
|
||||
"Only owners of a team can assign other users as owners."
|
||||
"Only owners of a template can assign other users as owners."
|
||||
)
|
||||
|
||||
attrs["team_id"] = self.context["team_id"]
|
||||
attrs["template_id"] = self.context["template_id"]
|
||||
return attrs
|
||||
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
"""Serialize teams."""
|
||||
class TemplateSerializer(serializers.ModelSerializer):
|
||||
"""Serialize templates."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
accesses = TeamAccessSerializer(many=True, read_only=True)
|
||||
accesses = TemplateAccessSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
fields = ["id", "name", "accesses", "abilities"]
|
||||
model = models.Template
|
||||
fields = ["id", "title", "accesses", "abilities"]
|
||||
read_only_fields = ["id", "accesses", "abilities"]
|
||||
|
||||
def get_abilities(self, team) -> dict:
|
||||
def get_abilities(self, template) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return team.get_abilities(request.user)
|
||||
return template.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class DocumentGenerationSerializer(serializers.Serializer):
|
||||
"""Serializer to receive a request to generate a document on a template."""
|
||||
|
||||
body = serializers.CharField(label=_("Markdown Body"))
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
"""API endpoints"""
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.core.cache import cache
|
||||
from io import BytesIO
|
||||
|
||||
from django.db.models import (
|
||||
Func,
|
||||
OuterRef,
|
||||
Q,
|
||||
Subquery,
|
||||
Value,
|
||||
)
|
||||
from django.http import FileResponse
|
||||
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
exceptions,
|
||||
mixins,
|
||||
pagination,
|
||||
response,
|
||||
status,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
|
||||
from core import models
|
||||
|
||||
from . import permissions, serializers
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
@@ -103,83 +107,6 @@ class Pagination(pagination.PageNumberPagination):
|
||||
page_size_query_param = "page_size"
|
||||
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
class ContactViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Contact ViewSet"""
|
||||
|
||||
permission_classes = [permissions.IsOwnedOrPublic]
|
||||
queryset = models.Contact.objects.all()
|
||||
serializer_class = serializers.ContactSerializer
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Limit listed users by a query with throttle protection."""
|
||||
user = self.request.user
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset.none()
|
||||
|
||||
# Exclude contacts that:
|
||||
queryset = queryset.filter(
|
||||
# - belong to another user (keep public and owned contacts)
|
||||
Q(owner__isnull=True) | Q(owner=user),
|
||||
# - are profile contacts for a user
|
||||
user__isnull=True,
|
||||
# - are overriden base contacts
|
||||
overriding_contacts__isnull=True,
|
||||
)
|
||||
|
||||
# Search by case-insensitive and accent-insensitive trigram similarity
|
||||
if query := self.request.GET.get("q", ""):
|
||||
query = Func(Value(query), function="unaccent")
|
||||
similarity = TrigramSimilarity(
|
||||
Func("full_name", function="unaccent"),
|
||||
query,
|
||||
) + TrigramSimilarity(Func("short_name", function="unaccent"), query)
|
||||
queryset = (
|
||||
queryset.annotate(similarity=similarity)
|
||||
.filter(
|
||||
similarity__gte=0.05
|
||||
) # Value determined by testing (test_api_contacts.py)
|
||||
.order_by("-similarity")
|
||||
)
|
||||
|
||||
# Throttle protection
|
||||
key_base = f"throttle-contact-list-{user.id!s}"
|
||||
key_minute = f"{key_base:s}-minute"
|
||||
key_hour = f"{key_base:s}-hour"
|
||||
|
||||
try:
|
||||
count_minute = cache.incr(key_minute)
|
||||
except ValueError:
|
||||
cache.set(key_minute, 1, 60)
|
||||
count_minute = 1
|
||||
|
||||
try:
|
||||
count_hour = cache.incr(key_hour)
|
||||
except ValueError:
|
||||
cache.set(key_hour, 1, 3600)
|
||||
count_hour = 1
|
||||
|
||||
if count_minute > 20 or count_hour > 150:
|
||||
raise exceptions.Throttled()
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created contact."""
|
||||
user = self.request.user
|
||||
serializer.validated_data["owner"] = user
|
||||
return super().perform_create(serializer)
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
@@ -202,12 +129,12 @@ class UserViewSet(
|
||||
Return information on currently logged user
|
||||
"""
|
||||
context = {"request": request}
|
||||
return response.Response(
|
||||
return drf_response.Response(
|
||||
self.serializer_class(request.user, context=context).data
|
||||
)
|
||||
|
||||
|
||||
class TeamViewSet(
|
||||
class TemplateViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
@@ -215,32 +142,69 @@ class TeamViewSet(
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Team ViewSet"""
|
||||
"""Template ViewSet"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
serializer_class = serializers.TeamSerializer
|
||||
queryset = models.Team.objects.all()
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticatedOrSafe,
|
||||
permissions.AccessPermission,
|
||||
]
|
||||
serializer_class = serializers.TemplateSerializer
|
||||
queryset = models.Template.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related teams."""
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=OuterRef("pk")
|
||||
"""Custom queryset to get user related templates."""
|
||||
if not self.request.user.is_authenticated:
|
||||
return models.Template.objects.filter(is_public=True)
|
||||
|
||||
user_role_query = models.TemplateAccess.objects.filter(
|
||||
user=self.request.user, template=OuterRef("pk")
|
||||
).values("role")[:1]
|
||||
return models.Team.objects.filter(accesses__user=self.request.user).annotate(
|
||||
user_role=Subquery(user_role_query)
|
||||
return (
|
||||
models.Template.objects.filter(
|
||||
Q(accesses__user=self.request.user) | Q(is_public=True)
|
||||
)
|
||||
.annotate(user_role=Subquery(user_role_query))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created team."""
|
||||
team = serializer.save()
|
||||
models.TeamAccess.objects.create(
|
||||
team=team,
|
||||
"""Set the current user as owner of the newly created template."""
|
||||
template = serializer.save()
|
||||
models.TemplateAccess.objects.create(
|
||||
template=template,
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="generate-document",
|
||||
permission_classes=[permissions.AccessPermission],
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def generate_document(self, request, pk=None):
|
||||
"""
|
||||
Generate and return pdf for this template with the content passed.
|
||||
"""
|
||||
serializer = serializers.DocumentGenerationSerializer(data=request.data)
|
||||
|
||||
class TeamAccessViewSet(
|
||||
if not serializer.is_valid():
|
||||
return drf_response.Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
body = serializer.validated_data["body"]
|
||||
|
||||
template = self.get_object()
|
||||
pdf_content = template.generate_document(body)
|
||||
|
||||
response = FileResponse(BytesIO(pdf_content), content_type="application/pdf")
|
||||
response["Content-Disposition"] = f"attachment; filename={template.title}.pdf"
|
||||
return response
|
||||
|
||||
|
||||
class TemplateAccessViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
@@ -249,37 +213,37 @@ class TeamAccessViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API ViewSet for all interactions with team accesses.
|
||||
API ViewSet for all interactions with template accesses.
|
||||
|
||||
GET /api/v1.0/teams/<team_id>/accesses/:<team_access_id>
|
||||
Return list of all team accesses related to the logged-in user or one
|
||||
team access if an id is provided.
|
||||
GET /api/v1.0/templates/<template_id>/accesses/:<template_access_id>
|
||||
Return list of all template accesses related to the logged-in user or one
|
||||
template access if an id is provided.
|
||||
|
||||
POST /api/v1.0/teams/<team_id>/accesses/ with expected data:
|
||||
POST /api/v1.0/templates/<template_id>/accesses/ with expected data:
|
||||
- user: str
|
||||
- role: str [owner|admin|member]
|
||||
Return newly created team access
|
||||
Return newly created template access
|
||||
|
||||
PUT /api/v1.0/teams/<team_id>/accesses/<team_access_id>/ with expected data:
|
||||
PUT /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
|
||||
- role: str [owner|admin|member]
|
||||
Return updated team access
|
||||
Return updated template access
|
||||
|
||||
PATCH /api/v1.0/teams/<team_id>/accesses/<team_access_id>/ with expected data:
|
||||
PATCH /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
|
||||
- role: str [owner|admin|member]
|
||||
Return partially updated team access
|
||||
Return partially updated template access
|
||||
|
||||
DELETE /api/v1.0/teams/<team_id>/accesses/<team_access_id>/
|
||||
Delete targeted team access
|
||||
DELETE /api/v1.0/templates/<template_id>/accesses/<template_access_id>/
|
||||
Delete targeted template access
|
||||
"""
|
||||
|
||||
lookup_field = "pk"
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
queryset = models.TeamAccess.objects.all().select_related("user")
|
||||
serializer_class = serializers.TeamAccessSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
|
||||
queryset = models.TemplateAccess.objects.all().select_related("user")
|
||||
serializer_class = serializers.TemplateAccessSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
"""User only needs to be authenticated to list team accesses"""
|
||||
"""User only needs to be authenticated to list template accesses"""
|
||||
if self.action == "list":
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
else:
|
||||
@@ -290,24 +254,26 @@ class TeamAccessViewSet(
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
context["team_id"] = self.kwargs["team_id"]
|
||||
context["template_id"] = self.kwargs["template_id"]
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = queryset.filter(team=self.kwargs["team_id"])
|
||||
queryset = queryset.filter(template=self.kwargs["template_id"])
|
||||
|
||||
if self.action == "list":
|
||||
# Limit to team access instances related to a team THAT also has a team access
|
||||
# instance for the logged-in user (we don't want to list only the team access
|
||||
# instances pointing to the logged-in user)
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
team__accesses__user=self.request.user
|
||||
# Limit to template access instances related to a template THAT also has
|
||||
# a template access
|
||||
# instance for the logged-in user (we don't want to list only the template
|
||||
# access instances pointing to the logged-in user)
|
||||
user_role_query = models.TemplateAccess.objects.filter(
|
||||
template=self.kwargs["template_id"],
|
||||
template__accesses__user=self.request.user,
|
||||
).values("role")[:1]
|
||||
queryset = (
|
||||
queryset.filter(
|
||||
team__accesses__user=self.request.user,
|
||||
template__accesses__user=self.request.user,
|
||||
)
|
||||
.annotate(user_role=Subquery(user_role_query))
|
||||
.distinct()
|
||||
@@ -317,13 +283,16 @@ class TeamAccessViewSet(
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Forbid deleting the last owner access"""
|
||||
instance = self.get_object()
|
||||
team = instance.team
|
||||
template = instance.template
|
||||
|
||||
# Check if the access being deleted is the last owner access for the team
|
||||
if instance.role == "owner" and team.accesses.filter(role="owner").count() == 1:
|
||||
return response.Response(
|
||||
{"detail": "Cannot delete the last owner access for the team."},
|
||||
status=400,
|
||||
# Check if the access being deleted is the last owner access for the template
|
||||
if (
|
||||
instance.role == "owner"
|
||||
and template.accesses.filter(role="owner").count() == 1
|
||||
):
|
||||
return drf_response.Response(
|
||||
{"detail": "Cannot delete the last owner access for the template."},
|
||||
status=403,
|
||||
)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
@@ -337,13 +306,13 @@ class TeamAccessViewSet(
|
||||
"role" in self.request.data
|
||||
and self.request.data["role"] != models.RoleChoices.OWNER
|
||||
):
|
||||
team = instance.team
|
||||
# Check if the access being updated is the last owner access for the team
|
||||
template = instance.template
|
||||
# Check if the access being updated is the last owner access for the template
|
||||
if (
|
||||
instance.role == models.RoleChoices.OWNER
|
||||
and team.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
|
||||
and template.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
|
||||
):
|
||||
message = "Cannot change the role to a non-owner role for the last owner access."
|
||||
raise exceptions.ValidationError({"role": message})
|
||||
raise exceptions.PermissionDenied({"detail": message})
|
||||
|
||||
serializer.save()
|
||||
|
||||
Reference in New Issue
Block a user