Afficher la photo du bénévole sur son écran et sur l'écran référent
Photo transmise via le formulaire public (Volunteer.photo) : nouveau
composant partagé VolunteerAvatar (photo si disponible, sinon repli sur
le carré d'initiales existant, même gabarit dans les deux cas).
- Écran bénévole (VolunteerHomeView) : photo dans le bandeau d'accueil,
à côté du message de bienvenue.
- Écran référent (MobileLeadPage) : photo devant le nom de chaque membre
de l'équipe, à la place du carré d'initiales.
GET .../volunteers/{id}/photo n'était accessible qu'à COORDINATION/
RÉFÉRENT ; ouvert aussi à l'intéressé lui-même (même garde que
get_volunteer), sinon l'écran bénévole ne pouvait jamais charger sa
propre photo.
Vérifié : pytest (274 passés, dont le nouveau test d'accès à sa propre
photo), ruff, tsc --noEmit, eslint, vite build, smoke — tous verts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
82088f3b0c
commit
111d1e5c4b
@@ -226,12 +226,14 @@ def get_volunteer_planning(
|
|||||||
def get_volunteer_photo(
|
def get_volunteer_photo(
|
||||||
volunteer_id: uuid.UUID,
|
volunteer_id: uuid.UUID,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
_guard: Account = Depends(require_staff),
|
account: Account = Depends(current_account_in_edition),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Photo transmise par le bénévole via le formulaire public
|
"""Photo transmise par le bénévole via le formulaire public
|
||||||
(`app/api/forms.py`) — réservée à COORDINATION/RÉFÉRENT, pas exposée à
|
(`app/api/forms.py`) — COORDINATION/RÉFÉRENT (ex. écran référent, en
|
||||||
l'écran bénévole."""
|
face du nom de chaque membre de l'équipe) ou le bénévole lui-même (écran
|
||||||
|
bénévole)."""
|
||||||
|
_ensure_can_read(account, volunteer_id)
|
||||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||||
if volunteer.photo is None:
|
if volunteer.photo is None:
|
||||||
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
|
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
|
||||||
|
|||||||
@@ -67,6 +67,22 @@ def test_volunteer_scope(session, edition, make_account, as_account):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_volunteer_sees_only_own_photo(session, edition, make_account, as_account):
|
||||||
|
me = make_volunteer(session, edition, email="me@e.fr")
|
||||||
|
other = make_volunteer(session, edition, email="other@e.fr")
|
||||||
|
me.photo = b"fake-bytes"
|
||||||
|
me.photo_content_type = "image/png"
|
||||||
|
other.photo = b"fake-bytes"
|
||||||
|
other.photo_content_type = "image/png"
|
||||||
|
session.add_all([me, other])
|
||||||
|
session.commit()
|
||||||
|
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="me@e.fr")
|
||||||
|
client = as_account(account)
|
||||||
|
|
||||||
|
assert client.get(_e(edition, f"/volunteers/{me.id}/photo")).status_code == 200
|
||||||
|
assert client.get(_e(edition, f"/volunteers/{other.id}/photo")).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_volunteer_sees_only_own_assignments(
|
def test_volunteer_sees_only_own_assignments(
|
||||||
session, edition, make_account, as_account
|
session, edition, make_account, as_account
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useVolunteerPhotoUrl } from '@/api/hooks';
|
||||||
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Photo du bénévole (formulaire public, voir `app/api/forms.py`) si
|
||||||
|
* disponible, sinon repli sur un carré d'initiales — même gabarit dans les
|
||||||
|
* deux cas pour ne jamais faire sauter la mise en page. `className` fixe la
|
||||||
|
* taille (ex. `h-[38px] w-[38px] text-[13px]`).
|
||||||
|
*/
|
||||||
|
export function VolunteerAvatar({
|
||||||
|
volunteerId,
|
||||||
|
hasPhoto,
|
||||||
|
initials,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
volunteerId: string;
|
||||||
|
hasPhoto: boolean;
|
||||||
|
initials: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const photoUrl = useVolunteerPhotoUrl(volunteerId, hasPhoto);
|
||||||
|
|
||||||
|
if (photoUrl) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={photoUrl}
|
||||||
|
alt=""
|
||||||
|
className={cx('flex-none border-[3px] border-ink object-cover', className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
'flex flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display font-extrabold text-ink',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/ho
|
|||||||
import { useAuth } from '@/auth/auth-context';
|
import { useAuth } from '@/auth/auth-context';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
import { NowLine } from '@/components/NowLine';
|
import { NowLine } from '@/components/NowLine';
|
||||||
|
import { VolunteerAvatar } from '@/components/VolunteerAvatar';
|
||||||
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
|
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
|
||||||
import { useNowMinutes } from '@/lib/useNowMinutes';
|
import { useNowMinutes } from '@/lib/useNowMinutes';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
@@ -122,12 +123,14 @@ export function MobileLeadPage() {
|
|||||||
const volunteer = volunteersById.get(a.volunteer_id);
|
const volunteer = volunteersById.get(a.volunteer_id);
|
||||||
return {
|
return {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
|
volunteerId: a.volunteer_id,
|
||||||
name: volunteer
|
name: volunteer
|
||||||
? `${volunteer.first_name} ${volunteer.last_name}`
|
? `${volunteer.first_name} ${volunteer.last_name}`
|
||||||
: a.volunteer_id,
|
: a.volunteer_id,
|
||||||
initials: volunteer
|
initials: volunteer
|
||||||
? `${volunteer.first_name[0]}${volunteer.last_name[0]}`
|
? `${volunteer.first_name[0]}${volunteer.last_name[0]}`
|
||||||
: '?',
|
: '?',
|
||||||
|
hasPhoto: volunteer?.has_photo ?? false,
|
||||||
phone: volunteer?.phone ?? '',
|
phone: volunteer?.phone ?? '',
|
||||||
present: presence[a.id] === 'present',
|
present: presence[a.id] === 'present',
|
||||||
absent: presence[a.id] === 'absent',
|
absent: presence[a.id] === 'absent',
|
||||||
@@ -155,9 +158,12 @@ export function MobileLeadPage() {
|
|||||||
className="border-[3px] border-ink bg-cream shadow-brutal-sm"
|
className="border-[3px] border-ink bg-cream shadow-brutal-sm"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
|
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
|
||||||
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[13px] font-extrabold">
|
<VolunteerAvatar
|
||||||
{member.initials}
|
volunteerId={member.volunteerId}
|
||||||
</span>
|
hasPhoto={member.hasPhoto}
|
||||||
|
initials={member.initials}
|
||||||
|
className="h-[38px] w-[38px] text-[13px]"
|
||||||
|
/>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block font-display text-[17px] font-extrabold leading-tight">
|
<span className="block font-display text-[17px] font-extrabold leading-tight">
|
||||||
{member.name}
|
{member.name}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Fragment, useMemo } from 'react';
|
|||||||
import { useVolunteer, useVolunteerPlanning } from '@/api/hooks';
|
import { useVolunteer, useVolunteerPlanning } from '@/api/hooks';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
import { NowLine } from '@/components/NowLine';
|
import { NowLine } from '@/components/NowLine';
|
||||||
|
import { VolunteerAvatar } from '@/components/VolunteerAvatar';
|
||||||
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
|
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
|
||||||
import { useNowMinutes } from '@/lib/useNowMinutes';
|
import { useNowMinutes } from '@/lib/useNowMinutes';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
@@ -39,6 +40,9 @@ export function VolunteerHomeView({
|
|||||||
if (planning.error) return <ErrorNote error={planning.error} />;
|
if (planning.error) return <ErrorNote error={planning.error} />;
|
||||||
|
|
||||||
const firstName = meVolunteer.data?.first_name ?? fallbackName ?? '';
|
const firstName = meVolunteer.data?.first_name ?? fallbackName ?? '';
|
||||||
|
const initials = meVolunteer.data
|
||||||
|
? `${meVolunteer.data.first_name[0]}${meVolunteer.data.last_name[0]}`
|
||||||
|
: (firstName[0] ?? '?');
|
||||||
|
|
||||||
const totalHours =
|
const totalHours =
|
||||||
Math.round(
|
Math.round(
|
||||||
@@ -55,10 +59,20 @@ export function VolunteerHomeView({
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
|
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
|
||||||
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7">
|
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7">
|
||||||
|
<div className="flex items-center gap-[12px]">
|
||||||
|
<VolunteerAvatar
|
||||||
|
volunteerId={volunteerId}
|
||||||
|
hasPhoto={meVolunteer.data?.has_photo ?? false}
|
||||||
|
initials={initials}
|
||||||
|
className="h-[52px] w-[52px] text-[18px]"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
|
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
|
||||||
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
|
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
|
||||||
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
|
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
||||||
{t('mobileVol.slots', { count: slots.length })}
|
{t('mobileVol.slots', { count: slots.length })}
|
||||||
|
|||||||
Reference in New Issue
Block a user