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

Ajout des invitations envoyées dans la page collaborateurs #520

Merged
merged 3 commits into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
2 changes: 1 addition & 1 deletion api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@
from .autocomplete_item import AutocompleteItemSerializer
from .declaration import DeclarationSerializer, DeclarationShortSerializer, SimpleDeclarationSerializer
from .company import CompanySerializer
from .solicitation import CoSupervisionClaimSerializer, AddNewCollaboratorSerializer
from .solicitation import CollaborationInvitationSerializer, CoSupervisionClaimSerializer, AddNewCollaboratorSerializer
11 changes: 10 additions & 1 deletion api/serializers/solicitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@

from rest_framework import serializers

from data.models import CoSupervisionClaim
from data.models import CollaborationInvitation, CoSupervisionClaim
from data.models.company import CompanyRoleClassChoices


class CollaborationInvitationSerializer(serializers.ModelSerializer):
sender_name = serializers.CharField(source="sender.name")

class Meta:
model = CollaborationInvitation
fields = ("id", "creation_date", "sender_name", "description", "recipient_email")
read_only_fields = fields


class CoSupervisionClaimSerializer(serializers.ModelSerializer):
sender_name = serializers.CharField(source="sender.name")

Expand Down
37 changes: 37 additions & 0 deletions api/tests/test_solicitation.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from rest_framework import status

from data.factories import (
CollaborationInvitationFactory,
CompanyFactory,
CoSupervisionClaimFactory,
SupervisorRoleFactory,
Expand All @@ -10,6 +11,42 @@
from .utils import ProjectAPITestCase


class TestListCollaborationInvitations(ProjectAPITestCase):
viewname = "list_collaboration_invitation"

def setUp(self):
self.user = UserFactory()
self.company_1 = CompanyFactory()
self.company_2 = CompanyFactory()
self.supervisor_role_1 = SupervisorRoleFactory(user=self.user, company=self.company_1)
self.supervisor_role_2 = SupervisorRoleFactory(user=self.user, company=self.company_2)

# solicitation déjà traitée (on la créé avant pour éviter une erreur d'intégrité à cause de la contrainte d'unicité)
CollaborationInvitationFactory(recipient_email=self.user.email, company=self.company_1).account_created(
processor=UserFactory()
)

self.solicitation_1 = CollaborationInvitationFactory(recipient_email=self.user.email, company=self.company_1)

# solicitation liée au même utilisateur mais à une entreprise différente
CollaborationInvitationFactory(recipient_email=self.user.email, company=self.company_2)

# solicitation pas liée à l'utilisateur du tout
self.company_3 = CompanyFactory()
CollaborationInvitationFactory(company=self.company_3)

def test_get_collaboration_invitations_ok(self):
self.login(self.user)
response = self.get(self.url(pk=self.company_1.id))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1) # seulement la solicitation 1

def test_get_collaboration_invitations_ko_not_mine(self):
self.login(self.user)
response = self.get(self.url(pk=self.company_3.id))
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) # get_or_404 appelé avant permission


class TestListCoSupervisionClaims(ProjectAPITestCase):
viewname = "list_co_supervision_claim"

Expand Down
5 changes: 5 additions & 0 deletions api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@
name="add_new_collaborator",
),
# Solicitations
path(
"companies/<int:pk>/collaboration-invitations/",
views.CollaborationInvitationListView.as_view(),
name="list_collaboration_invitation",
),
path(
"companies/<int:pk>/co-supervision-claims/",
views.CoSupervisionClaimListView.as_view(),
Expand Down
7 changes: 6 additions & 1 deletion api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,9 @@
AddCompanyRoleView,
RemoveCompanyRoleView,
)
from .solicitation import ProcessCoSupervisionClaim, CoSupervisionClaimListView, AddNewCollaboratorView
from .solicitation import (
ProcessCoSupervisionClaim,
CoSupervisionClaimListView,
CollaborationInvitationListView,
AddNewCollaboratorView,
)
11 changes: 10 additions & 1 deletion api/views/solicitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,20 @@

from ..exception_handling import ProjectAPIException
from ..permissions import IsSolicitationRecipient, IsSupervisor
from ..serializers import AddNewCollaboratorSerializer, CoSupervisionClaimSerializer
from ..serializers import AddNewCollaboratorSerializer, CollaborationInvitationSerializer, CoSupervisionClaimSerializer

User = get_user_model()


class CollaborationInvitationListView(ListAPIView):
Copy link
Collaborator

Choose a reason for hiding this comment

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

Est-ce que ça vaut la peine d'ajouter une permission IsAuthenticated ? Ça permettrait de renvoyer un 403 et non pas un 404 pour le cas où l'on n'est pas auth.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oui tout à fait, merci, ajouté (en plus ça renvoyait même pas un 404 mais un 500 haha)

serializer_class = CollaborationInvitationSerializer

def get_queryset(self):
user = self.request.user
company = get_object_or_404(Company.objects.filter(supervisors=user), pk=self.kwargs["pk"])
return CollaborationInvitation.objects.filter(company=company, processor__isnull=True)


class CoSupervisionClaimListView(ListAPIView):
serializer_class = CoSupervisionClaimSerializer

Expand Down
2 changes: 1 addition & 1 deletion data/admin/solicitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class WithDisplayRecipients:
def display_recipients(self, obj):
recipient_count = obj.recipients.count()
if recipient_count == 1:
return obj.recipient.get().name
return obj.recipients.get().name
else:
return f"{recipient_count} destinataires"

Expand Down
10 changes: 10 additions & 0 deletions frontend/src/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,13 @@ export const RiSurveyFill = remixIcon(
"ri-survey-fill",
"M6 4v4h12V4h2.007c.548 0 .993.445.993.993v16.014a.994.994 0 01-.993.993H3.993A.994.994 0 013 21.007V4.993C3 4.445 3.445 4 3.993 4H6zm3 13H7v2h2v-2zm0-3H7v2h2v-2zm0-3H7v2h2v-2zm7-9v4H8V2h8z"
)

export const RiChatDownloadLine = remixIcon(
"ri-chat-download-line",
"M6.455 19L2 22.5V4a1 1 0 011-1h18a1 1 0 011 1v14a1 1 0 01-1 1H6.455zM4 18.385L5.763 17H20V5H4v13.385zM13 11h3l-4 4-4-4h3V7h2v4z"
)

export const RiChatUploadLine = remixIcon(
"ri-chat-upload-line",
"M6.455 19L2 22.5V4a1 1 0 011-1h18a1 1 0 011 1v14a1 1 0 01-1 1H6.455zM4 18.385L5.763 17H20V5H4v13.385zM13 11v4h-2v-4H8l4-4 4 4h-3z"
)
66 changes: 14 additions & 52 deletions frontend/src/views/CollaboratorsPage/ClaimsBlock.vue
Original file line number Diff line number Diff line change
@@ -1,67 +1,29 @@
<template>
<div v-if="solicitations">
<SectionTitle title="Demandes reçues" icon="ri-chat-3-line" />
<p>Visualisez et traitez vos demandes reçues pour rejoindre votre entreprise.</p>
<div v-for="solicitation in solicitations" :key="solicitation.id">
<div class="flex items-center">
<v-icon class="size-4" name="ri-chat-3-line" />

<div class="ml-2">
<div>{{ solicitation.senderName }}</div>
<div class="text-xs">
{{ isoToPrettyDate(solicitation.creationDate, dateOptions) }}
à {{ isoToPrettyTime(solicitation.creationDate) }}
</div>
</div>

<div class="ml-2 md:ml-8 flex flex-col gap-y-1">
<div class="italic">{{ solicitation.description }}</div>
<div class="flex gap-x-2">
<DsfrButton
v-for="action in actions"
:key="action.label"
:label="action.label"
:icon="action.icon"
:primary="action.primary"
:secondary="action.secondary"
size="sm"
@click="process(solicitation.id, action.name)"
/>
</div>
</div>
</div>
<hr class="mt-4 -mb-2 border" />
</div>
<div v-if="solicitations.length === 0">
<p class="italic">Vous n'avez actuellement aucune demande en cours.</p>
</div>
</div>
<SolicitationsHolder
v-if="solicitations"
@process="process"
title="Demandes reçues"
icon="ri-chat-download-line"
description="Visualisez et traitez vos demandes reçues pour rejoindre votre entreprise."
:solicitations="solicitations"
emptyText="Vous n'avez actuellement aucune demande en cours."
:actions="[
{ name: 'accept', label: 'Accepter', primary: true, icon: 'ri-check-fill' },
{ name: 'refuse', label: 'Refuser', secondary: true, icon: 'ri-close-fill' },
]"
/>
</template>

<script setup>
import SectionTitle from "@/components/SectionTitle"
import { useFetch } from "@vueuse/core"
import { handleError } from "@/utils/error-handling"
import { headers } from "@/utils/data-fetching"
import { onMounted } from "vue"
import { isoToPrettyDate, isoToPrettyTime } from "@/utils/date"
import useToaster from "@/composables/use-toaster"
import SolicitationsHolder from "./SolicitationsHolder"

const props = defineProps({ companyId: Number, collaboratorsExecute: Function })

const dateOptions = {
weekday: "short",
month: "short",
day: "numeric",
}

// Pour le moment, les actions possibles sont identiques entre toutes les solicitations, donc on garde ça hardcodé.
// A terme, on pourra imaginer un mapping côté front, ou même que le back-end retourne les différentes actions possibles.
const actions = [
{ name: "accept", label: "Accepter", primary: true, icon: "ri-check-fill" },
{ name: "refuse", label: "Refuser", secondary: true, icon: "ri-close-fill" },
]

const {
data: solicitations,
response,
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/views/CollaboratorsPage/SentInvitationsBlock.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<template>
<SolicitationsHolder
v-if="solicitations"
title="Invitations envoyées"
icon="ri-chat-upload-line"
description="Visualisez les invitations envoyées par les co-gestionnaires à rejoindre votre entreprise."
:solicitations="solicitations"
emptyText="Il n'y a aucune invitation en cours"
:actions="[]"
showRecipientEmail
/>
</template>

<script setup>
import { useFetch } from "@vueuse/core"
import { handleError } from "@/utils/error-handling"
import { headers } from "@/utils/data-fetching"
import { onMounted } from "vue"
import SolicitationsHolder from "./SolicitationsHolder"

const props = defineProps({ companyId: Number })

const {
data: solicitations,
response,
execute,
} = useFetch(
`/api/v1/companies/${props.companyId}/collaboration-invitations/`,
{
headers: headers(),
},
{ immediate: false }
).json()

onMounted(async () => {
await execute()
await handleError(response)
})
</script>
63 changes: 63 additions & 0 deletions frontend/src/views/CollaboratorsPage/SolicitationsHolder.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<template>
<div>
<SectionTitle :title="title" :icon="icon" />
<p>{{ description }}</p>
<div v-for="solicitation in solicitations" :key="solicitation.id">
<div class="flex items-center">
<v-icon class="size-4" name="ri-chat-download-line" />

<div class="ml-2">
<div>{{ solicitation.senderName }}</div>
<div class="-mt-1.5" v-if="showRecipientEmail">à {{ solicitation.recipientEmail }}</div>
<div class="text-xs">
{{ isoToPrettyDate(solicitation.creationDate, dateOptions) }}
à {{ isoToPrettyTime(solicitation.creationDate) }}
</div>
</div>

<div class="ml-2 md:ml-8 flex flex-col gap-y-1">
<div class="italic">{{ solicitation.description }}</div>
<div v-if="actions.length > 0" class="flex gap-x-2">
<DsfrButton
v-for="action in actions"
:key="action.label"
:label="action.label"
:icon="action.icon"
:primary="action.primary"
:secondary="action.secondary"
size="sm"
@click="emit('process', solicitation.id, action.name)"
/>
</div>
</div>
</div>
<hr class="mt-4 -mb-2 border" />
</div>
<div v-if="solicitations.length === 0">
<p class="italic">{{ emptyText }}</p>
</div>
</div>
</template>

<script setup>
import SectionTitle from "@/components/SectionTitle"
import { isoToPrettyDate, isoToPrettyTime } from "@/utils/date"

defineProps({
title: String,
icon: String,
description: String,
solicitations: Array,
emptyText: String,
actions: Array,
showRecipientEmail: { type: Boolean, default: false }, // spécifique
})

const emit = defineEmits(["process"])

const dateOptions = {
weekday: "short",
month: "short",
day: "numeric",
}
</script>
2 changes: 2 additions & 0 deletions frontend/src/views/CollaboratorsPage/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
<hr class="mt-4 -mb-2 border" />
</div>
<ClaimsBlock class="mt-8" :companyId="company.id" :collaboratorsExecute="collaboratorsExecute" />
<SentInvitationsBlock class="mt-8" :companyId="company.id" />
</div>
</template>

Expand All @@ -57,6 +58,7 @@ import RoleTag from "@/components/RoleTag.vue"
import { headers } from "@/utils/data-fetching"
import { roleNameDisplayNameMapping } from "@/utils/mappings"
import ClaimsBlock from "./ClaimsBlock"
import SentInvitationsBlock from "./SentInvitationsBlock.vue"
import AddNewCollaborator from "./AddNewCollaborator"

const store = useRootStore()
Expand Down
Loading