diff --git a/frontend/src/hooks/useGuests/index.tsx b/frontend/src/hooks/useGuests/index.tsx index d69df315c5cc22c94ac88e6edf542f0bd76a5bf0..23f5a54f3c87d7818cb6949278e9444343e73265 100644 --- a/frontend/src/hooks/useGuests/index.tsx +++ b/frontend/src/hooks/useGuests/index.tsx @@ -4,6 +4,7 @@ import { parseIdentity, parseRole, fetchJsonOpts } from 'utils' const useGuests = () => { const [guests, setGuests] = useState<Guest[]>([]) + const [loading, setLoading] = useState<boolean>(true) const getGuestsInfo = () => fetch('/api/ui/v1/guests/', fetchJsonOpts()) @@ -28,8 +29,12 @@ const useGuests = () => { }) ) ) + setLoading(false) + }) + .catch(() => { + setGuests([]) + setLoading(false) }) - .catch(() => setGuests([])) const reloadGuests = () => { getGuestsInfo() @@ -39,7 +44,7 @@ const useGuests = () => { getGuestsInfo() }, []) - return { guests, reloadGuests } + return { guests, reloadGuests, loading } } export default useGuests diff --git a/frontend/src/routes/sponsor/frontpage/index.tsx b/frontend/src/routes/sponsor/frontpage/index.tsx index 5036a661d31f45322f58c90ee27f3a2b7c930fc1..84bf384beec8237679f82aebde74233c08886392 100644 --- a/frontend/src/routes/sponsor/frontpage/index.tsx +++ b/frontend/src/routes/sponsor/frontpage/index.tsx @@ -17,6 +17,7 @@ import { import { styled } from '@mui/system' import { useState } from 'react' +import Loading from 'components/loading' import Page from 'components/page' import { differenceInDays, format, isBefore } from 'date-fns' import { Guest, Role } from 'interfaces' @@ -47,6 +48,7 @@ interface GuestTableProps { interface FrontPageProps { guests: Guest[] + loading: boolean } const StyledTableRow = styled(TableRow)({ @@ -401,15 +403,22 @@ const WaitingGuests = ({ persons }: GuestProps) => { ) } -function FrontPage({ guests }: FrontPageProps) { +function FrontPage({ guests, loading }: FrontPageProps) { return ( <Page pageWidth> <SponsorGuestButtons yourGuestsActive /> - <InvitedGuests persons={guests} /> - <br /> - <WaitingGuests persons={guests} /> - <br /> - <ActiveGuests persons={guests} /> + + {loading ? ( + <Loading /> + ) : ( + <> + <InvitedGuests persons={guests} /> + <br /> + <WaitingGuests persons={guests} /> + <br /> + <ActiveGuests persons={guests} /> + </> + )} </Page> ) } diff --git a/frontend/src/routes/sponsor/index.tsx b/frontend/src/routes/sponsor/index.tsx index 989d75489615475f365ff8ab5defae7c6ba6c08d..9e272f50cadf4c2fb58f32ae4537b75d93e61b74 100644 --- a/frontend/src/routes/sponsor/index.tsx +++ b/frontend/src/routes/sponsor/index.tsx @@ -5,7 +5,7 @@ import useGuests from 'hooks/useGuests' import GuestRoutes from './guest' function Sponsor() { - const { guests, reloadGuests } = useGuests() + const { guests, reloadGuests, loading } = useGuests() return ( <Routes> @@ -13,7 +13,10 @@ function Sponsor() { path="guest/:pid/*" element={<GuestRoutes reloadGuests={reloadGuests} />} /> - <Route path="" element={<FrontPage guests={guests} />} /> + <Route + path="" + element={<FrontPage guests={guests} loading={loading} />} + /> </Routes> ) } diff --git a/greg/models.py b/greg/models.py index 59d909bd4843f9ed0dc407deddeabe102092a460..1f3b6c4fb9ee2938067d992fda2cbb87f311e72f 100644 --- a/greg/models.py +++ b/greg/models.py @@ -709,7 +709,11 @@ class InvitationLink(BaseModel): uuid = models.UUIDField(null=False, default=uuid.uuid4, blank=False) invitation = models.ForeignKey( - "Invitation", on_delete=models.CASCADE, null=False, blank=False + "Invitation", + on_delete=models.CASCADE, + null=False, + blank=False, + related_name="invitation_links", ) expire = models.DateTimeField(blank=False, null=False) @@ -727,7 +731,13 @@ class Invitation(BaseModel): Deleting the InvitedPerson deletes the Invitation. """ - role = models.ForeignKey("Role", null=False, blank=False, on_delete=models.CASCADE) + role = models.ForeignKey( + "Role", + null=False, + blank=False, + on_delete=models.CASCADE, + related_name="invitations", + ) def __str__(self) -> str: return f"{self.__class__.__name__}(id={self.pk}, role={self.role})" diff --git a/gregui/api/serializers/guest.py b/gregui/api/serializers/guest.py index b7474eade11c78ef2552d2398c3e061eb9d1cb8b..ca63e30dc54bc0067a454cf3a84ca7818990ab87 100644 --- a/gregui/api/serializers/guest.py +++ b/gregui/api/serializers/guest.py @@ -1,4 +1,5 @@ import datetime +from typing import Literal from django.conf import settings from django.utils import timezone @@ -288,3 +289,93 @@ class GuestSerializer(serializers.ModelSerializer): "invitation_status", "roles", ] + + +class FrontPageGuestSerializer(serializers.ModelSerializer): + """ + Serializer used for presenting guests to sponsors. + """ + + pid = CharField(source="id", read_only=True) + first = CharField(source="first_name", read_only=True) + last = CharField(source="last_name", read_only=True) + active = SerializerMethodField(source="active", read_only=True) + registered = BooleanField(source="is_registered", read_only=True) + verified = SerializerMethodField(source="verified", read_only=True) + invitation_status = SerializerMethodField( + source="get_invitation_status", read_only=True + ) + roles = ExtendedRoleSerializer(many=True, read_only=True) + + def get_active(self, obj: Person) -> bool: + return obj.is_registered and self.get_verified(obj) + + def get_verified(self, obj: Person) -> bool: + return ( + len( + [ + x + for x in obj.identities.all() + if x.type + in [ + Identity.IdentityType.NORWEGIAN_NATIONAL_ID_NUMBER, + Identity.IdentityType.PASSPORT_NUMBER, + ] + and x.verified_at + and x.verified_at <= timezone.now() + ] + ) + >= 1 + ) + + def get_invitation_status( + self, obj: Person + ) -> Literal["active", "expired", "invalidEmail", "none"]: + + private_email = next( + iter( + [ + x + for x in obj.identities.all() + if x.type == Identity.IdentityType.PRIVATE_EMAIL + ] + ), + None, + ) + + if private_email and private_email.invalid: + return "invalidEmail" + + invitations = [ + item + for sublist in [list(x.invitations.all()) for x in obj.roles.all()] + for item in sublist + ] + + # Get all invitation_links from all invitations and flatten them + invitation_links = [ + item + for sublist in [list(x.invitation_links.all()) for x in invitations] + for item in sublist + ] + + non_expired_links = [x for x in invitation_links if x.expire > timezone.now()] + + if len(non_expired_links) > 0: + return "active" + if len(invitation_links) > 0: + return "expired" + return "none" + + class Meta: + model = Person + fields = [ + "pid", + "first", + "last", + "active", + "registered", + "verified", + "invitation_status", + "roles", + ] diff --git a/gregui/api/views/person.py b/gregui/api/views/person.py index 4cfbafbf21f9db4975c124c3c2b1880fbb50a5e6..c2a06e138118e01063b32fd8a82d6b51e1536c20 100644 --- a/gregui/api/views/person.py +++ b/gregui/api/views/person.py @@ -1,5 +1,4 @@ -from rest_framework import mixins -from rest_framework import status +from rest_framework import mixins, status from rest_framework.authentication import SessionAuthentication, BasicAuthentication from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -11,6 +10,7 @@ from greg.utils import is_identity_duplicate from gregui import validation from gregui.api.serializers.guest import ( GuestSerializer, + FrontPageGuestSerializer, create_identity_or_update, ) from gregui.api.serializers.identity import IdentityDuplicateError @@ -121,7 +121,7 @@ class GuestInfoViewSet(mixins.ListModelMixin, GenericViewSet): authentication_classes = [SessionAuthentication, BasicAuthentication] permission_classes = [IsAuthenticated, IsSponsor] - serializer_class = GuestSerializer + serializer_class = FrontPageGuestSerializer def get_queryset(self): """ @@ -135,6 +135,16 @@ class GuestInfoViewSet(mixins.ListModelMixin, GenericViewSet): units = user.sponsor.get_allowed_units() return ( Person.objects.filter(roles__orgunit__in=list(units)) + .prefetch_related( + "identities", + "roles", + "roles__sponsor", + "roles__orgunit", + "roles__person", + "roles__type", + "roles__invitations", + "roles__invitations__invitation_links", + ) .distinct() .order_by("id") )