Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion ami/user/api_urls.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from django.urls import path

from .api_views import consent, registrations, unregister
from .api_views import consent, consents, registrations, unregister

urlpatterns = [
path("users/registrations", registrations),
path("users/registrations/<uuid:registration_id>", unregister),
path("consent/<str:fc_hash>", consent),
path("users/consents", consents),
]
35 changes: 35 additions & 0 deletions ami/user/api_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
ConsentPostResponseSerializer,
ConsentPostSerializer,
ConsentResponseSerializer,
ConsentSerializer,
ConsentUpdateSerializer,
MobileAppSubscriptionSerializer,
RegistrationCreateSerializer,
RegistrationSerializer,
Expand Down Expand Up @@ -156,3 +158,36 @@ def consent(request: Request, fc_hash: str) -> Response:
{"message": "Consent given" if data["consent"] else "Consent withdrawn"}
)
return Response(response_serializer.data)


@extend_schema(
methods=["POST"],
request=ConsentUpdateSerializer,
)
@api_view(["GET", "POST"])
@ami_login_required
def consents(request: Request) -> Response:
if request.method == "GET":
consents_qs: QuerySet[Consent] = request.ami_user.consent_set.all()
return Response(ConsentSerializer(consents_qs, many=True).data)

serializer = ConsentUpdateSerializer(data=request.data)
try:
serializer.is_valid(raise_exception=True)
except serializers.ValidationError:
logger.exception("Internal post consent serialization error")
raise
data: dict = cast(dict, serializer.validated_data)

consent_datetime = now() if data["consent"] else None
Consent.objects.update_or_create(
user=request.ami_user,
partner_id=data["partner_id"],
defaults={"consent_datetime": consent_datetime},
create_defaults={"consent_datetime": consent_datetime},
)

response_serializer = ConsentPostResponseSerializer(
{"message": "Consent given" if data["consent"] else "Consent withdrawn"}
)
return Response(response_serializer.data)
11 changes: 11 additions & 0 deletions ami/user/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,14 @@ class ConsentPostSerializer(serializers.Serializer):

class ConsentPostResponseSerializer(serializers.Serializer):
message = serializers.ChoiceField(choices=["Consent given", "Consent withdrawn"])


class ConsentSerializer(serializers.Serializer):
id = serializers.UUIDField()
partner_id = serializers.CharField()
consent_datetime = serializers.DateTimeField()


class ConsentUpdateSerializer(serializers.Serializer):
partner_id = serializers.CharField()
consent = serializers.BooleanField()
90 changes: 90 additions & 0 deletions ami/user/tests/test_consent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pytest
from django.utils.timezone import now

from ami.tests.utils import assert_query_fails_without_auth, login
from ami.user.models import Consent, User


Expand Down Expand Up @@ -165,3 +166,92 @@ def test_post_consent_without_auth(app, settings) -> None:

b64 = base64.b64encode("dinum-ami:foo".encode("utf8")).decode("utf8")
app.post("/api/v1/consent/fake-fc-hash", headers={"authorization": f"Basic {b64}"}, status=401)


@pytest.mark.django_db
def test_get_consents(app, user: User) -> None:
login(app, user)

consent_datetime = datetime.datetime(2020, 12, 25, 17, 5, 55, tzinfo=datetime.timezone.utc)
consent = Consent.objects.create(user=user, partner_id="psl", consent_datetime=consent_datetime)

response = app.get("/api/v1/users/consents", status=200)
consents = response.json
assert len(consents) == 1
assert set(response.json[0].keys()) == {"consent_datetime", "id", "partner_id"}
assert response.json[0]["id"] == str(consent.id)
assert response.json[0]["partner_id"] == consent.partner_id
assert response.json[0]["consent_datetime"] == "2020-12-25T17:05:55Z"


@pytest.mark.django_db
def test_get_consents_without_auth(app) -> None:
assert_query_fails_without_auth(app, "/api/v1/users/consents")


@pytest.mark.django_db
def test_post_consents(
app,
two_users: list[User],
) -> None:
login(app, two_users[0])

Consent.objects.create(user=two_users[0], partner_id="psl", consent_datetime=now())
Consent.objects.create(user=two_users[1], partner_id="dinum-ami", consent_datetime=now())

data = {"partner_id": "dinum-ami", "consent": True}
response = app.post_json("/api/v1/users/consents", data)
assert response.json == {"message": "Consent given"}
assert Consent.objects.count() == 3
consent = Consent.objects.latest("created_at")
assert consent.user == two_users[0]
assert consent.partner_id == "dinum-ami"
assert consent.consent_datetime is not None

data = {"partner_id": "dinum-ami", "consent": False}
response = app.post_json("/api/v1/users/consents", data)
assert response.json == {"message": "Consent withdrawn"}
assert Consent.objects.count() == 3
consent.refresh_from_db()
assert consent.user == two_users[0]
assert consent.partner_id == "dinum-ami"
assert consent.consent_datetime is None


@pytest.mark.django_db
def test_post_consents_user_consent_invalid(
app,
user: User,
) -> None:
login(app, user)

data = {"partner_id": "dinum-ami"}
response = app.post_json("/api/v1/users/consents", data, status=400)
assert response.json == {"consent": ["Ce champ est obligatoire."]}
assert Consent.objects.count() == 0
assert User.objects.count() == 1

data = {"partner_id": "dinum-ami", "consent": "invalid"}
response = app.post_json("/api/v1/users/consents", data, status=400)
assert response.json == {"consent": ["Must be a valid boolean."]}
assert Consent.objects.count() == 0
assert User.objects.count() == 1


@pytest.mark.django_db
def test_post_consents_without_auth(app, settings) -> None:
app.post("/api/v1/users/consents", status=401)

app.post("/api/v1/users/consents", headers={"authorization": "foo"}, status=401)

app.post("/api/v1/users/consents", headers={"authorization": "Foo bar"}, status=401)

app.post("/api/v1/users/consents", headers={"authorization": "Basic bar"}, status=401)

b64 = base64.b64encode(f"foo:{settings.PARTNERS_DINUM_AMI_SECRET}".encode("utf8")).decode(
"utf8"
)
app.post("/api/v1/users/consents", headers={"authorization": f"Basic {b64}"}, status=401)

b64 = base64.b64encode("dinum-ami:foo".encode("utf8")).decode("utf8")
app.post("/api/v1/users/consents", headers={"authorization": f"Basic {b64}"}, status=401)
67 changes: 44 additions & 23 deletions public/mobile-app/src/lib/ConnectedHomepage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
import AgendaItem from '$lib/components/AgendaItem.svelte';
import AutoPromoCarousel from '$lib/components/AutoPromo.svelte';
import AutoPromoItem from '$lib/components/AutoPromoItem.svelte';
import FollowupItem from '$lib/components/FollowupItem.svelte';
import FollowupItem from '$lib/components/followup/FollowupItem.svelte';
import FollowupNoConsent from '$lib/components/followup/FollowupNoConsent.svelte';
import AgendaItemModal from '$lib/components/modal/AgendaItemModal.svelte';
import CenteredModal from '$lib/components/modal/CenteredModal.svelte';
import FollowupItemModal from '$lib/components/modal/FollowupItemModal.svelte';
import { buildConsents, hasAnyConsents as hasAnyConsentsFunc } from '$lib/consents';
import type { Followup, FollowupItem as FollowupItemType } from '$lib/followup';
import { buildFollowup } from '$lib/followup';
import {
Expand All @@ -29,6 +30,7 @@
let selectedAgendaItem: AgendaItemType | null = $state(null);
let selectedFollowupItem: FollowupItemType | null = $state(null);
let autoPromo: AutoPromo | null = $state(null);
let hasAnyConsents: boolean = $state(false);

onMount(async () => {
console.log('User is connected:', userStore.connected);
Expand Down Expand Up @@ -65,6 +67,8 @@
followup = await buildFollowup();
console.log($state.snapshot(followup));
isFollowupEmpty = !followup.items.length;
await buildConsents();
hasAnyConsents = await hasAnyConsentsFunc();
} catch (error) {
console.error(error);
}
Expand Down Expand Up @@ -156,20 +160,43 @@
{/if}
</div>

<div class="rubrique-container followup-container">
{#if isFollowupEmpty}
<div class="header-container fr-mb-1w">
<h2 class="fr-h6 fr-mb-0 am-text--smbold title">Mes démarches</h2>
</div>
<div class="rubrique-content-container">
<div class="no-followup rubrique-content-container--empty">
<div class="no-followup--icon">
<img src="/remixicons/tracking.svg" alt="">
{#if hasAnyConsents}
<div class="rubrique-container followup-container">
{#if isFollowupEmpty}
<div class="header-container fr-mb-1w">
<h2 class="fr-h6 fr-mb-0 am-text--smbold title">Mes démarches</h2>
</div>
<div class="rubrique-content-container">
<div class="no-followup rubrique-content-container--empty">
<div class="no-followup--icon">
<img src="/remixicons/tracking.svg" alt="">
</div>
<div class="no-followup--title">Suivez vos démarches ici.</div>
</div>
<div class="no-followup--title">Retrouvez et suivez vos démarches ici.</div>
</div>
</div>
{:else}
{:else}
<div class="header-container fr-mb-1w">
<h2 class="fr-h6 fr-mb-0 am-text--smbold title">Mes démarches</h2>
<button
type="button"
class="fr-link fr-icon-arrow-right-line fr-link--icon-right am-link-icon-xl"
aria-label="Voir toutes mes démarches"
onclick={() => goto("/#/followup")}
></button>
</div>
<div class="rubrique-content-container">
{#if followup && followup.items.length}
{@const firstItem = followup.items[0]}
<FollowupItem
item={firstItem}
onOpen={() => openFollowupItemModal(firstItem)}
/>
{/if}
</div>
{/if}
</div>
{:else}
<div class="rubrique-container followup-container">
<div class="header-container fr-mb-1w">
<h2 class="fr-h6 fr-mb-0 am-text--smbold title">Mes démarches</h2>
<button
Expand All @@ -180,16 +207,10 @@
></button>
</div>
<div class="rubrique-content-container">
{#if followup && followup.items.length}
{@const firstItem = followup.items[0]}
<FollowupItem
item={firstItem}
onOpen={() => openFollowupItemModal(firstItem)}
/>
{/if}
<FollowupNoConsent />
</div>
{/if}
</div>
</div>
{/if}
</div>

{#if selectedAgendaItem}
Expand Down
49 changes: 45 additions & 4 deletions public/mobile-app/src/lib/ConnectedHomepage.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import type { WS as WSType } from 'vitest-websocket-mock';
import WS from 'vitest-websocket-mock';
import * as agendaMethods from '$lib/agenda';
import { Agenda, Item } from '$lib/agenda';
import type { APIConsents, APIConsentsItem } from '$lib/api-consents';
import * as autoPromoMethods from '$lib/auto-promo';
import { AutoPromo, AutoPromoItem } from '$lib/auto-promo';
import * as consentsMethods from '$lib/consents';
import { Consents } from '$lib/consents';
import * as followupMethods from '$lib/followup';
import { Followup, FollowupItem } from '$lib/followup';
import * as notificationsMethods from '$lib/notifications';
Expand Down Expand Up @@ -46,6 +49,7 @@ describe('/ConnectedHomepage.svelte', () => {
new AutoPromo(new Agenda())
);
vi.spyOn(followupMethods, 'buildFollowup').mockResolvedValue(new Followup());
vi.spyOn(consentsMethods, 'buildConsents').mockResolvedValue(new Consents());

window.localStorage.setItem('notifications_enabled', 'false');
window.localStorage.setItem('user_data', 'fake-user-data');
Expand Down Expand Up @@ -345,7 +349,20 @@ describe('/ConnectedHomepage.svelte', () => {
});
});

describe('Followup block', () => {
describe('Followup block - when user has consented', () => {
beforeEach(async () => {
const apiConsentsItem: APIConsentsItem = {
partner_id: 'fake-partner-id',
consent_datetime: new Date('2026-02-22T15:55:00.000Z'),
};
const apiConsents: APIConsents = {
consents: [apiConsentsItem],
};
const consents: Consents = new Consents(apiConsents);
vi.spyOn(consentsMethods, 'buildConsents').mockResolvedValue(consents);
vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(true);
});

test('Should display first followup found from API', async () => {
// Given
const followup = new Followup();
Expand Down Expand Up @@ -464,9 +481,7 @@ describe('/ConnectedHomepage.svelte', () => {
await waitFor(() => {
const followupBlock = container.querySelector('.followup-container');
expect(spy).toHaveBeenCalledTimes(1);
expect(followupBlock).toHaveTextContent(
'Retrouvez et suivez vos démarches ici.'
);
expect(followupBlock).toHaveTextContent('Suivez vos démarches ici.');
});
});

Expand Down Expand Up @@ -724,4 +739,30 @@ describe('/ConnectedHomepage.svelte', () => {
});
});
});

describe('Followup block - when user has not consented', () => {
beforeEach(async () => {
const apiConsents: APIConsents = {
consents: [],
};
const consents: Consents = new Consents(apiConsents);
vi.spyOn(consentsMethods, 'buildConsents').mockResolvedValue(consents);
vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(false);
});

test('should display followup no consent block', async () => {
// When
const { container } = render(ConnectedHomepage);

// Then
await waitFor(() => {
const followupNoConsentBlock = container.querySelector(
'.followup-no-consent-container'
);
expect(followupNoConsentBlock).toHaveTextContent(
'Suivez vos démarches administratives au même endroit !'
);
});
});
});
});
Loading
Loading