♻️(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:
Samuel Paccoud - DINUM
2024-02-09 19:32:12 +01:00
committed by Samuel Paccoud
parent 0a3e26486e
commit 0f9327a1de
41 changed files with 2259 additions and 2567 deletions

View File

@@ -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()