From ac163e88542d34c73ee8c10384804309388131ec Mon Sep 17 00:00:00 2001 From: "Mr. Paul" Date: Tue, 8 Oct 2024 21:36:33 -0700 Subject: [PATCH 1/8] Create Event Store module The Event Store can be implemented in this Python module. --- backend/app/core/event_store.py | 77 ++++++++++++++++++++++++ backend/tests/unit/access/test_invite.py | 43 +++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 backend/app/core/event_store.py create mode 100644 backend/tests/unit/access/test_invite.py diff --git a/backend/app/core/event_store.py b/backend/app/core/event_store.py new file mode 100644 index 000000000..9fbe2c40c --- /dev/null +++ b/backend/app/core/event_store.py @@ -0,0 +1,77 @@ +from abc import abstractmethod +from typing import Any, Protocol +from dataclasses import dataclass +import uuid +import datetime +import json + + +class DomainEvent: + pass + + +class AppendOnlyStoreConcurrencyException(Exception): + pass + + +class EventStore(Protocol): + """Abstraction for something that stores domain events.""" + + @abstractmethod + def fetch(self, instance_id: uuid.UUID) -> list[DomainEvent]: + raise NotImplementedError + + @abstractmethod + def append(self, instance_id: uuid.UUID, new_events: list[DomainEvent], + expected_version: int): + raise NotImplementedError + + +@dataclass +class DomainEventStream: + version: int + events: list[DomainEvent] + + +class InMemoryEventStore: + + @dataclass + class EventStoreRow: + stream_id: uuid.UUID + stream_version: int + event_name: Any + event_data: Any + meta_data: dict[str, Any] + stored_at: datetime.datetime + + def __init__(self): + self.events: dict[int, list[self.EventStoreRow]] = {} + + def fetch(self, instance_id: uuid.UUID) -> DomainEventStream: + stream = DomainEventStream(version=1, events=[]) + + for row in self.events.get(instance_id, []): + stream.version = row.stream_version + stream.events.append(json.load(row.event_data, row.event_name)) + + return stream + + def append(self, instance_id: uuid.UUID, new_events: list[DomainEvent], + expected_version: int): + rows = self.events.get(instance_id, []) + + version = max(row.stream_version for row in rows) + if version != expected_version: + raise AppendOnlyStoreConcurrencyException() + + rows.extend([ + self.EventStoreRow( + stream_id=instance_id, + stream_version=version + 1, + event_name=type(e), + event_data=json.dump(e), + stored_at=datetime.datetime.utcnow(), + ) for e in new_events + ]) + + self.events[instance_id] = rows diff --git a/backend/tests/unit/access/test_invite.py b/backend/tests/unit/access/test_invite.py new file mode 100644 index 000000000..afb695355 --- /dev/null +++ b/backend/tests/unit/access/test_invite.py @@ -0,0 +1,43 @@ +from app.core.event_store import DomainEvent, InMemoryEventStore + +import datetime +from dataclasses import dataclass +import uuid + +@dataclass +class InviteSentDomainEvent(DomainEvent): + email: str + invitee_role: str + token: str + sent_at: datetime.datetime + sent_by: str + +@dataclass +class InviteState: + id: uuid.UUID + invited: bool + accepted: bool + +class Invite: + + def __init__(self, domain_events: list[DomainEvent]): + self.state = InviteState(invited=False, accepted=False) + self.changes: list[DomainEvent] = [] + for domain_event in domain_events: + self.mutate(domain_event) + + def apply(self, domain_event: DomainEvent): + self.changes.append(domain_event) + self.mutate(domain_event) + + def mutate(self, domain_event: DomainEvent): + self.__dict__['when_'+domain_event.__class__.__name__](domain_event) + + def when_InviteSentDomainEvent(self, event: InviteSentDomainEvent): + self.state.invited = True + + def send_invite(self, full_name: str, email: str, invitee_role: str, sent_by: str): + pass + +def test_send_invite(): + event_store = InMemoryEventStore() From 49c7ef6379316f07dd69d93c14fa3686e1a6445b Mon Sep 17 00:00:00 2001 From: "Mr. Paul" Date: Mon, 14 Oct 2024 23:05:28 -0700 Subject: [PATCH 2/8] Implement basic Event Store and Invite slices A basic in-memory event store along with example Invite features. --- backend/app/core/event_store.py | 65 ++++--- backend/app/core/interfaces.py | 17 ++ .../app/modules/access/invite/contracts.py | 65 +++++++ .../modules/access/invite/invite_aggregate.py | 86 +++++++++ backend/tests/unit/access/test_invite.py | 182 ++++++++++++++---- backend/tests/unit/core/test_event_store.py | 45 +++++ 6 files changed, 399 insertions(+), 61 deletions(-) create mode 100644 backend/app/core/interfaces.py create mode 100644 backend/app/modules/access/invite/contracts.py create mode 100644 backend/app/modules/access/invite/invite_aggregate.py create mode 100644 backend/tests/unit/core/test_event_store.py diff --git a/backend/app/core/event_store.py b/backend/app/core/event_store.py index 9fbe2c40c..6556028b5 100644 --- a/backend/app/core/event_store.py +++ b/backend/app/core/event_store.py @@ -1,15 +1,12 @@ +from .interfaces import Identity, DomainEvent from abc import abstractmethod -from typing import Any, Protocol +from typing import Any, Protocol from dataclasses import dataclass -import uuid -import datetime +from datetime import datetime, timezone +import importlib import json -class DomainEvent: - pass - - class AppendOnlyStoreConcurrencyException(Exception): pass @@ -18,11 +15,11 @@ class EventStore(Protocol): """Abstraction for something that stores domain events.""" @abstractmethod - def fetch(self, instance_id: uuid.UUID) -> list[DomainEvent]: + def fetch(self, stream_id: Identity) -> list[DomainEvent]: raise NotImplementedError @abstractmethod - def append(self, instance_id: uuid.UUID, new_events: list[DomainEvent], + def append(self, stream_id: Identity, new_events: list[DomainEvent], expected_version: int): raise NotImplementedError @@ -37,41 +34,53 @@ class InMemoryEventStore: @dataclass class EventStoreRow: - stream_id: uuid.UUID + stream_id: str stream_version: int - event_name: Any - event_data: Any + event_data: str meta_data: dict[str, Any] - stored_at: datetime.datetime + stored_at: datetime def __init__(self): self.events: dict[int, list[self.EventStoreRow]] = {} - def fetch(self, instance_id: uuid.UUID) -> DomainEventStream: - stream = DomainEventStream(version=1, events=[]) + def fetch(self, stream_id: Identity) -> DomainEventStream: + stream = DomainEventStream(version=0, events=[]) - for row in self.events.get(instance_id, []): + for row in self.events.get(stream_id, []): stream.version = row.stream_version - stream.events.append(json.load(row.event_data, row.event_name)) + stream.events.append( + self._deserialize_event(json.loads(row.event_data))) return stream - def append(self, instance_id: uuid.UUID, new_events: list[DomainEvent], + def append(self, stream_id: Identity, new_events: list[DomainEvent], expected_version: int): - rows = self.events.get(instance_id, []) + rows = self.events.get(stream_id, []) - version = max(row.stream_version for row in rows) + version = len(rows) if version != expected_version: - raise AppendOnlyStoreConcurrencyException() + raise AppendOnlyStoreConcurrencyException( + f"version={version}, expected={expected_version}") rows.extend([ self.EventStoreRow( - stream_id=instance_id, - stream_version=version + 1, - event_name=type(e), - event_data=json.dump(e), - stored_at=datetime.datetime.utcnow(), - ) for e in new_events + stream_id=str(stream_id), + stream_version=version + inc, + event_data=json.dumps(e.to_dict(), default=str), + meta_data={}, + stored_at=datetime.now(tz=timezone.utc), + ) for inc, e in enumerate(new_events, start=1) ]) - self.events[instance_id] = rows + self.events[stream_id] = rows + + def _deserialize_event(self, event_data): + """Convert a dictionary back to the correct event class.""" + fully_qualified_type = event_data["type"] + module_name, class_name = fully_qualified_type.rsplit(".", 1) + + # Dynamically import the module and get the class + module = importlib.import_module(module_name) + event_class = getattr(module, class_name) + + return event_class.from_dict(event_data["data"]) diff --git a/backend/app/core/interfaces.py b/backend/app/core/interfaces.py new file mode 100644 index 000000000..85fb0752b --- /dev/null +++ b/backend/app/core/interfaces.py @@ -0,0 +1,17 @@ +from typing import Any + + +class Identity: + """Represent identity value.""" + + id: Any + + +class DomainEvent: + """Represent a domain event""" + + def to_dict(self): + return { + 'type': f"{self.__class__.__module__}.{self.__class__.__name__}", + 'data': self.__dict__, + } diff --git a/backend/app/modules/access/invite/contracts.py b/backend/app/modules/access/invite/contracts.py new file mode 100644 index 000000000..fb8275838 --- /dev/null +++ b/backend/app/modules/access/invite/contracts.py @@ -0,0 +1,65 @@ +"""The identities, classes, value objects that make up the Invite contracts.""" +from app.core.interfaces import Identity, DomainEvent + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +@dataclass(frozen=True) +class InviteId(Identity): + """The identity of an Invite.""" + + id: str + + def __str__(self): + """Represent Invite ID as a string.""" + return f"invite-{self.id}" + + +@dataclass +class InviteSentDomainEvent(DomainEvent): + """An Invite domain event.""" + + full_name: str + email: str + invitee_role: str + sent_by: str + sent_at: datetime + token: str + + @classmethod + def from_dict(cls, data: dict[str, Any]): + """Deserialize from dict to the correct event class.""" + sent_at = datetime.fromisoformat(data['sent_at']) + return cls(full_name=data['full_name'], + email=data['email'], + invitee_role=data['invitee_role'], + sent_by=data['sent_by'], + sent_at=sent_at, + token=data['token']) + + +@dataclass +class InviteAcceptedDomainEvent(DomainEvent): + """An Invite was accepted domain event.""" + + email: str + token: str + + @classmethod + def from_dict(cls, data: dict[str, Any]): + """Deserialize from dict to the correct event class.""" + return cls(email=data['email'], token=data['token']) + + +class InviteAlreadySentException(Exception): + """Invite was already sent.""" + + pass + + +class UninvitedException(Exception): + """An invite was accepted without an invitation.""" + + pass diff --git a/backend/app/modules/access/invite/invite_aggregate.py b/backend/app/modules/access/invite/invite_aggregate.py new file mode 100644 index 000000000..6001b8a33 --- /dev/null +++ b/backend/app/modules/access/invite/invite_aggregate.py @@ -0,0 +1,86 @@ +"""The Invite aggregate handles invites into the system.""" +from app.core.interfaces import DomainEvent +from .contracts import ( + InviteId, + InviteSentDomainEvent, + InviteAcceptedDomainEvent, + InviteAlreadySentException, + UninvitedException, +) + +from collections.abc import Callable +from datetime import datetime, timezone + + +class InviteState: + """Holds the state of the Invite aggregate.""" + + id: InviteId + full_name: str = None + email: str = None + invitee_role: str = None + sent_by: str = None + sent_at: datetime = None + invited: bool = False + accepted: bool = False + + def __init__(self, domain_events: list[DomainEvent]): + """Initialize state from given events.""" + for domain_event in domain_events: + self.mutate(domain_event) + + def mutate(self, domain_event: DomainEvent): + """Update the state based on the domain event.""" + getattr(self, 'when_' + domain_event.__class__.__name__)(domain_event) + + def when_InviteSentDomainEvent(self, event: InviteSentDomainEvent): + """Update the state of an Invite.""" + self.full_name = event.full_name + self.email = event.email + self.invitee_role = event.invitee_role + self.sent_by = event.sent_by + self.sent_at = event.sent_at + self.invited = True + + def when_InviteAcceptedDomainEvent(self, event: InviteAcceptedDomainEvent): + """Update state of an Invite.""" + self.accepted = True + +class Invite: + """Allows invites to be sent and managed by existing users.""" + + def __init__(self, domain_events: list[DomainEvent]): + """Initialize an Invite.""" + self._state = InviteState(domain_events) + self._changes: list[DomainEvent] = [] + + def _apply(self, domain_event: DomainEvent): + self._state.mutate(domain_event) + self._changes.append(domain_event) + + def changes(self): + """See a view into the events that cause state changes.""" + return self._changes + + def send_invite(self, full_name: str, email: str, invitee_role: str, + sent_by: str, token_gen: Callable[[str], str]): + """Send an invite to the given recipient.""" + if self._state.invited: + raise InviteAlreadySentException(email) + + e = InviteSentDomainEvent(full_name=full_name, + email=email, + invitee_role=invitee_role, + sent_by=sent_by, + sent_at=datetime.now(timezone.utc), + token=token_gen(email)) + self._apply(e) + + def accept_invite(self, email: str, token: str): + """Accept an invite.""" + if not self._state.invited: + raise UninvitedException(f"{email} was not invited.") + + e = InviteAcceptedDomainEvent(email=email, token=token) + + self._apply(e) diff --git a/backend/tests/unit/access/test_invite.py b/backend/tests/unit/access/test_invite.py index afb695355..141ab3410 100644 --- a/backend/tests/unit/access/test_invite.py +++ b/backend/tests/unit/access/test_invite.py @@ -1,43 +1,159 @@ -from app.core.event_store import DomainEvent, InMemoryEventStore +from app.modules.access.invite.contracts import ( + InviteId, + InviteSentDomainEvent, + InviteAcceptedDomainEvent, + InviteAlreadySentException, + UninvitedException, +) +from app.modules.access.invite.invite_aggregate import Invite -import datetime -from dataclasses import dataclass -import uuid +from datetime import datetime, timezone -@dataclass -class InviteSentDomainEvent(DomainEvent): - email: str - invitee_role: str - token: str - sent_at: datetime.datetime - sent_by: str +import pytest -@dataclass -class InviteState: - id: uuid.UUID - invited: bool - accepted: bool -class Invite: +def then(given, when, expected_events): - def __init__(self, domain_events: list[DomainEvent]): - self.state = InviteState(invited=False, accepted=False) - self.changes: list[DomainEvent] = [] - for domain_event in domain_events: - self.mutate(domain_event) + invite = Invite(given) + when(invite) - def apply(self, domain_event: DomainEvent): - self.changes.append(domain_event) - self.mutate(domain_event) + assert expected_events == invite.changes() + assert invite._state.invited - def mutate(self, domain_event: DomainEvent): - self.__dict__['when_'+domain_event.__class__.__name__](domain_event) - def when_InviteSentDomainEvent(self, event: InviteSentDomainEvent): - self.state.invited = True +def thenException(given, when, exception_class): - def send_invite(self, full_name: str, email: str, invitee_role: str, sent_by: str): - pass + invite = Invite(given) + with pytest.raises(exception_class): + when(invite) -def test_send_invite(): - event_store = InMemoryEventStore() + +def test_send_invite(monkeypatch): + + # Setup + fixed_datetime = datetime(2020, 6, 15, 12, 0, 0, tzinfo=timezone.utc) + + class fakedatetime: + + @classmethod + def now(cls, tzinfo): + return fixed_datetime + + # Mock datetime.now to return the fixed_datetime + monkeypatch.setattr(Invite.__module__ + '.datetime', fakedatetime) + + full_name = 'test name' + email = 'test@email.com' + invitee_role = 'Coordinator' + sent_by = 'coordinators' + + token_gen = lambda email: email + + # Given + # No prior events + given = [] + + # When + when = lambda invite: invite.send_invite(full_name=full_name, + email=email, + invitee_role=invitee_role, + sent_by=sent_by, + token_gen=token_gen) + + # Then + expected_events = [ + InviteSentDomainEvent(full_name=full_name, + email=email, + invitee_role=invitee_role, + sent_by=sent_by, + sent_at=fixed_datetime, + token=token_gen(email)) + ] + then(given, when, expected_events) + + +def test_send_duplicate_invite(): + + # Setup + full_name = 'test name' + email = 'test@email.com' + invitee_role = 'Coordinator' + sent_by = 'coordinators' + + token_gen = lambda email: email + + # Given + given = [ + InviteSentDomainEvent(full_name=full_name, + email=email, + invitee_role=invitee_role, + sent_by=sent_by, + sent_at=datetime.now(timezone.utc), + token=token_gen(email)) + ] + + # When + when = lambda invite: invite.send_invite(full_name=full_name, + email=email, + invitee_role=invitee_role, + sent_by=sent_by, + token_gen=token_gen) + + # Then + thenException(given, when, InviteAlreadySentException) + + +def test_invite_accepted(): + + # Setup + fixed_datetime = datetime(2020, 6, 15, 12, 0, 0, tzinfo=timezone.utc) + + full_name = 'test name' + email = 'test@email.com' + invitee_role = 'Coordinator' + sent_by = 'coordinators' + + token_gen = lambda email: email + + # Given + given = [ + InviteSentDomainEvent(full_name=full_name, + email=email, + invitee_role=invitee_role, + sent_by=sent_by, + sent_at=fixed_datetime, + token=token_gen(email)) + ] + + # When + when = lambda invite: invite.accept_invite(email=email, + token=token_gen(email)) + + # Then + expected_events = [ + InviteAcceptedDomainEvent(email=email, token=token_gen(email)) + ] + then(given, when, expected_events) + + +def test_uninvited_invite_accepted(): + + # Setup + fixed_datetime = datetime(2020, 6, 15, 12, 0, 0, tzinfo=timezone.utc) + + full_name = 'test name' + email = 'test@email.com' + invitee_role = 'Coordinator' + sent_by = 'coordinators' + + token_gen = lambda email: email + + # Given + given = [] + + # When + when = lambda invite: invite.accept_invite(email=email, + token=token_gen(email)) + + # Then + thenException(given, when, UninvitedException) diff --git a/backend/tests/unit/core/test_event_store.py b/backend/tests/unit/core/test_event_store.py new file mode 100644 index 000000000..c3f26df74 --- /dev/null +++ b/backend/tests/unit/core/test_event_store.py @@ -0,0 +1,45 @@ +from app.core.event_store import DomainEvent, InMemoryEventStore + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class Event1(DomainEvent): + test: str + + @classmethod + def from_dict(cls, data: dict[str, Any]): + return cls(test=data['test']) + + +@dataclass(frozen=True) +class Event2(DomainEvent): + test: int + + @classmethod + def from_dict(cls, data: dict[str, Any]): + return cls(test=int(data['test'])) + + +def test_in_memory_event_store(): + events = [Event1("x"), Event2(1)] + stream_id = "test-stream" + + event_store = InMemoryEventStore() + + event_stream = event_store.fetch(stream_id) + assert len(event_stream.events) == 0 + assert event_stream.version == 0 + + event_store.append(stream_id, events, event_stream.version) + event_stream = event_store.fetch(stream_id) + assert len(event_stream.events) == 2 + assert event_stream.version == 2 + assert event_stream.events == events + + event_store.append(stream_id, events, event_stream.version) + event_stream = event_store.fetch(stream_id) + assert len(event_stream.events) == 4 + assert event_stream.version == 4 + assert event_stream.events == events + events From 9aa734b8a5117fefec0529c46f4dc96470a84f5d Mon Sep 17 00:00:00 2001 From: "Mr. Paul" Date: Tue, 15 Oct 2024 19:21:08 -0700 Subject: [PATCH 3/8] Update Invite contracts and add DomainCommand marker class --- backend/app/core/interfaces.py | 48 ++++++++++++++++++- .../app/modules/access/invite/contracts.py | 12 ++++- .../modules/access/invite/invite_aggregate.py | 9 ++-- backend/tests/unit/access/test_invite.py | 36 +++++--------- 4 files changed, 75 insertions(+), 30 deletions(-) diff --git a/backend/app/core/interfaces.py b/backend/app/core/interfaces.py index 85fb0752b..c6c5f9e97 100644 --- a/backend/app/core/interfaces.py +++ b/backend/app/core/interfaces.py @@ -1,16 +1,60 @@ +"""This module defines marker classes for domain classes. + +The classes defined here are used by domain classes to help identify +the general responsibility that they represent. +""" from typing import Any class Identity: - """Represent identity value.""" + """Represent identity value. + + Each aggregate and entity have a unique identity that is + represented in the form of a property in its class definition. + + The data type of the identity will be specific to the class. + For example, a Person class can have an identity represented + as: + + class Person: + personId: PersonId + + class PersonId(Identity): + id: uuid.UUID + """ id: Any +class DomainCommand: + """Representation for an intention to change state of the system. + + This is a marker class to identify classes as being Commands. + """ + + class DomainEvent: - """Represent a domain event""" + """Represent a domain event. + + This is a marker class to identify classes as being Domain Events. + """ def to_dict(self): + """Turn the class into a dictionary. + + This is used to help with JSON de/serialization. + + A class that inherits from this class must implement a + class method that can convert a dictionary to an object + of that class: + + class ExampleEvent(DomainEvent): + example_property: int + + @classmethod + def from_dict(cls, data: dict[str, Any]): + return cls(example_property=int(data['example_property'])) + """ return { 'type': f"{self.__class__.__module__}.{self.__class__.__name__}", 'data': self.__dict__, diff --git a/backend/app/modules/access/invite/contracts.py b/backend/app/modules/access/invite/contracts.py index fb8275838..686f9e3a1 100644 --- a/backend/app/modules/access/invite/contracts.py +++ b/backend/app/modules/access/invite/contracts.py @@ -1,5 +1,5 @@ """The identities, classes, value objects that make up the Invite contracts.""" -from app.core.interfaces import Identity, DomainEvent +from app.core.interfaces import Identity, DomainCommand, DomainEvent from dataclasses import dataclass from datetime import datetime @@ -17,6 +17,16 @@ def __str__(self): return f"invite-{self.id}" +class SendInviteCommand(DomainCommand): + """Command with data needed to send an Invite.""" + + full_name: str + email: str + invitee_role: str + sent_by: str + sent_at: datetime + + @dataclass class InviteSentDomainEvent(DomainEvent): """An Invite domain event.""" diff --git a/backend/app/modules/access/invite/invite_aggregate.py b/backend/app/modules/access/invite/invite_aggregate.py index 6001b8a33..17a14c088 100644 --- a/backend/app/modules/access/invite/invite_aggregate.py +++ b/backend/app/modules/access/invite/invite_aggregate.py @@ -9,7 +9,7 @@ ) from collections.abc import Callable -from datetime import datetime, timezone +from datetime import datetime class InviteState: @@ -46,6 +46,7 @@ def when_InviteAcceptedDomainEvent(self, event: InviteAcceptedDomainEvent): """Update state of an Invite.""" self.accepted = True + class Invite: """Allows invites to be sent and managed by existing users.""" @@ -58,12 +59,14 @@ def _apply(self, domain_event: DomainEvent): self._state.mutate(domain_event) self._changes.append(domain_event) + @property def changes(self): """See a view into the events that cause state changes.""" return self._changes def send_invite(self, full_name: str, email: str, invitee_role: str, - sent_by: str, token_gen: Callable[[str], str]): + sent_by: str, sent_at: datetime, token_gen: Callable[[str], + str]): """Send an invite to the given recipient.""" if self._state.invited: raise InviteAlreadySentException(email) @@ -72,7 +75,7 @@ def send_invite(self, full_name: str, email: str, invitee_role: str, email=email, invitee_role=invitee_role, sent_by=sent_by, - sent_at=datetime.now(timezone.utc), + sent_at=sent_at, token=token_gen(email)) self._apply(e) diff --git a/backend/tests/unit/access/test_invite.py b/backend/tests/unit/access/test_invite.py index 141ab3410..6bd43f43d 100644 --- a/backend/tests/unit/access/test_invite.py +++ b/backend/tests/unit/access/test_invite.py @@ -11,53 +11,43 @@ import pytest - def then(given, when, expected_events): invite = Invite(given) when(invite) - assert expected_events == invite.changes() + assert expected_events == invite.changes assert invite._state.invited -def thenException(given, when, exception_class): +def thenException(given, when, expected_exception_class): invite = Invite(given) - with pytest.raises(exception_class): + with pytest.raises(expected_exception_class): when(invite) -def test_send_invite(monkeypatch): +def test_send_invite(): # Setup fixed_datetime = datetime(2020, 6, 15, 12, 0, 0, tzinfo=timezone.utc) - - class fakedatetime: - - @classmethod - def now(cls, tzinfo): - return fixed_datetime - - # Mock datetime.now to return the fixed_datetime - monkeypatch.setattr(Invite.__module__ + '.datetime', fakedatetime) - full_name = 'test name' email = 'test@email.com' invitee_role = 'Coordinator' sent_by = 'coordinators' + sent_at = fixed_datetime - token_gen = lambda email: email + token_gen = lambda email: 'token-' + email # Given - # No prior events - given = [] + given = [] # No prior events # When when = lambda invite: invite.send_invite(full_name=full_name, email=email, invitee_role=invitee_role, sent_by=sent_by, + sent_at=sent_at, token_gen=token_gen) # Then @@ -66,7 +56,7 @@ def now(cls, tzinfo): email=email, invitee_role=invitee_role, sent_by=sent_by, - sent_at=fixed_datetime, + sent_at=sent_at, token=token_gen(email)) ] then(given, when, expected_events) @@ -97,6 +87,8 @@ def test_send_duplicate_invite(): email=email, invitee_role=invitee_role, sent_by=sent_by, + sent_at=datetime.now(tz=timezone. + utc), token_gen=token_gen) # Then @@ -106,8 +98,6 @@ def test_send_duplicate_invite(): def test_invite_accepted(): # Setup - fixed_datetime = datetime(2020, 6, 15, 12, 0, 0, tzinfo=timezone.utc) - full_name = 'test name' email = 'test@email.com' invitee_role = 'Coordinator' @@ -121,7 +111,7 @@ def test_invite_accepted(): email=email, invitee_role=invitee_role, sent_by=sent_by, - sent_at=fixed_datetime, + sent_at=datetime.now(tz=timezone.utc), token=token_gen(email)) ] @@ -139,8 +129,6 @@ def test_invite_accepted(): def test_uninvited_invite_accepted(): # Setup - fixed_datetime = datetime(2020, 6, 15, 12, 0, 0, tzinfo=timezone.utc) - full_name = 'test name' email = 'test@email.com' invitee_role = 'Coordinator' From 4116362ebab336e52e80b3a90d625e0092ab1e8b Mon Sep 17 00:00:00 2001 From: "Mr. Paul" Date: Sat, 19 Oct 2024 09:51:11 -0700 Subject: [PATCH 4/8] Adding Value Object marker and domain specific implementations Starting to see what it looks like to represent domain concepts in the commands and events. Instead of using string for every value, use a domain concept to represent the value types. --- backend/app/core/interfaces.py | 8 +++++++ .../app/modules/access/invite/contracts.py | 22 ++++++++++--------- .../modules/access/invite/invite_aggregate.py | 15 +++++++------ backend/app/modules/access/models.py | 21 +++++++++++++++++- 4 files changed, 48 insertions(+), 18 deletions(-) diff --git a/backend/app/core/interfaces.py b/backend/app/core/interfaces.py index c6c5f9e97..2346b3851 100644 --- a/backend/app/core/interfaces.py +++ b/backend/app/core/interfaces.py @@ -26,6 +26,14 @@ class PersonId(Identity): id: Any +class ValueObject: + """Represents a Value Object. + + A Value Object has significance to the domain and should be a valid + representation of the type of thing it represents. It is also immutable. + """ + + class DomainCommand: """Representation for an intention to change state of the system. diff --git a/backend/app/modules/access/invite/contracts.py b/backend/app/modules/access/invite/contracts.py index 686f9e3a1..154109924 100644 --- a/backend/app/modules/access/invite/contracts.py +++ b/backend/app/modules/access/invite/contracts.py @@ -1,5 +1,6 @@ """The identities, classes, value objects that make up the Invite contracts.""" from app.core.interfaces import Identity, DomainCommand, DomainEvent +from ..schemas import UserRoleEnum from dataclasses import dataclass from datetime import datetime @@ -22,8 +23,9 @@ class SendInviteCommand(DomainCommand): full_name: str email: str - invitee_role: str - sent_by: str + invitee_role: UserRoleEnum + inviter_id: UserId + inviter_role: UserRoleEnum sent_at: datetime @@ -33,10 +35,10 @@ class InviteSentDomainEvent(DomainEvent): full_name: str email: str - invitee_role: str - sent_by: str + invitee_role: UserRoleEnum + inviter_id: UserId + inviter_role: UserRoleEnum sent_at: datetime - token: str @classmethod def from_dict(cls, data: dict[str, Any]): @@ -45,9 +47,9 @@ def from_dict(cls, data: dict[str, Any]): return cls(full_name=data['full_name'], email=data['email'], invitee_role=data['invitee_role'], - sent_by=data['sent_by'], - sent_at=sent_at, - token=data['token']) + inviter_id=data['inviter_id'], + inviter_role=data['inviter_role'] + sent_at=sent_at) @dataclass @@ -55,12 +57,12 @@ class InviteAcceptedDomainEvent(DomainEvent): """An Invite was accepted domain event.""" email: str - token: str + accepted_at: datetime @classmethod def from_dict(cls, data: dict[str, Any]): """Deserialize from dict to the correct event class.""" - return cls(email=data['email'], token=data['token']) + return cls(email=data['email'], accepted_at=data['accepted_at']) class InviteAlreadySentException(Exception): diff --git a/backend/app/modules/access/invite/invite_aggregate.py b/backend/app/modules/access/invite/invite_aggregate.py index 17a14c088..c4c4fa2f0 100644 --- a/backend/app/modules/access/invite/invite_aggregate.py +++ b/backend/app/modules/access/invite/invite_aggregate.py @@ -1,5 +1,6 @@ """The Invite aggregate handles invites into the system.""" from app.core.interfaces import DomainEvent +from ..schemas import UserRoleEnum from .contracts import ( InviteId, InviteSentDomainEvent, @@ -15,14 +16,14 @@ class InviteState: """Holds the state of the Invite aggregate.""" - id: InviteId + email: InviteId = None full_name: str = None - email: str = None - invitee_role: str = None - sent_by: str = None + invitee_role: UserRoleEnum = None + inviter_id: UserId = None + inviter_role: UserRoleEnum = None sent_at: datetime = None - invited: bool = False - accepted: bool = False + expire_at: datetime = None + accepted_at: datetime = None def __init__(self, domain_events: list[DomainEvent]): """Initialize state from given events.""" @@ -44,7 +45,7 @@ def when_InviteSentDomainEvent(self, event: InviteSentDomainEvent): def when_InviteAcceptedDomainEvent(self, event: InviteAcceptedDomainEvent): """Update state of an Invite.""" - self.accepted = True + self.accepted_at = event.accepted_at class Invite: diff --git a/backend/app/modules/access/models.py b/backend/app/modules/access/models.py index 226ed6af3..5e27ce92c 100644 --- a/backend/app/modules/access/models.py +++ b/backend/app/modules/access/models.py @@ -1,5 +1,7 @@ """Model.""" +from email_validator import validate_email, EmailNotValidError + from sqlalchemy import Column, ForeignKey, Integer, String from sqlalchemy.orm import relationship from sqlalchemy.orm import validates as validates_sqlachemy @@ -8,6 +10,7 @@ from sqlalchemy.exc import SQLAlchemyError from app.core.db import Base +from app.core.interfaces import ValueObject class User(Base): @@ -34,4 +37,20 @@ class Role(Base): id = Column(Integer, primary_key=True, index=True) type = Column(String, nullable=False, unique=True) - users = relationship("User", back_populates="role") \ No newline at end of file + users = relationship("User", back_populates="role") + +class Email(ValueObject): + """Represent a valid email address.""" + + def __init__(self, email: str): + try: + emailinfo = validate_email(email, check_deliverability=False) + + self.email = emailinfo.normalized + + except EmailNotValidError as e: + raise e + + @property + def email(self): + return self.email From 25b24ad4c8cbf000c9fb38c25fd6da7659d3e762 Mon Sep 17 00:00:00 2001 From: "Mr. Paul" Date: Tue, 22 Oct 2024 16:22:56 -0700 Subject: [PATCH 5/8] Add SQLAlchemy Event Store, Update Invite flow, Value Object This commit includes an example of a "Value Object" named EmailAddress. It represents a valid email address. Consumers of this object can expect that the object is valid according to the data it contains such that the consumer does not have to check if the email is valid. An implementation of a SQLAlchemy-backed Event Store was also added along with a unit test using a SQLite in-memory. The Invite flow spec and code has been updated to include the idea of an invite that is pending to be sent. --- backend/app/core/event_store.py | 24 ++-- backend/app/core/sa_event_store.py | 117 +++++++++++++++ .../app/modules/access/invite/contracts.py | 26 +++- .../modules/access/invite/invite_aggregate.py | 55 ++++--- backend/app/modules/access/models.py | 44 +++++- .../unit/access/test_email_value_object.py | 39 +++++ backend/tests/unit/access/test_invite.py | 135 +++++++++++------- backend/tests/unit/core/test_event_store.py | 35 ++++- 8 files changed, 380 insertions(+), 95 deletions(-) create mode 100644 backend/app/core/sa_event_store.py create mode 100644 backend/tests/unit/access/test_email_value_object.py diff --git a/backend/app/core/event_store.py b/backend/app/core/event_store.py index 6556028b5..808e0c8fd 100644 --- a/backend/app/core/event_store.py +++ b/backend/app/core/event_store.py @@ -1,11 +1,11 @@ -from .interfaces import Identity, DomainEvent from abc import abstractmethod -from typing import Any, Protocol from dataclasses import dataclass from datetime import datetime, timezone import importlib import json +from typing import Any, Protocol +from .interfaces import Identity, DomainEvent class AppendOnlyStoreConcurrencyException(Exception): pass @@ -33,7 +33,7 @@ class DomainEventStream: class InMemoryEventStore: @dataclass - class EventStoreRow: + class EventStreamEntry: stream_id: str stream_version: int event_data: str @@ -41,29 +41,29 @@ class EventStoreRow: stored_at: datetime def __init__(self): - self.events: dict[int, list[self.EventStoreRow]] = {} + self.events: dict[int, list[self.EventStreamEntry]] = {} def fetch(self, stream_id: Identity) -> DomainEventStream: stream = DomainEventStream(version=0, events=[]) - for row in self.events.get(stream_id, []): - stream.version = row.stream_version + for stream_entry in self.events.get(stream_id, []): + stream.version = stream_entry.stream_version stream.events.append( - self._deserialize_event(json.loads(row.event_data))) + self._deserialize_event(json.loads(stream_entry.event_data))) return stream def append(self, stream_id: Identity, new_events: list[DomainEvent], expected_version: int): - rows = self.events.get(stream_id, []) + stream_entries = self.events.get(stream_id, []) - version = len(rows) + version = len(stream_entries) if version != expected_version: raise AppendOnlyStoreConcurrencyException( f"version={version}, expected={expected_version}") - rows.extend([ - self.EventStoreRow( + stream_entries.extend([ + self.EventStreamEntry( stream_id=str(stream_id), stream_version=version + inc, event_data=json.dumps(e.to_dict(), default=str), @@ -72,7 +72,7 @@ def append(self, stream_id: Identity, new_events: list[DomainEvent], ) for inc, e in enumerate(new_events, start=1) ]) - self.events[stream_id] = rows + self.events[stream_id] = stream_entries def _deserialize_event(self, event_data): """Convert a dictionary back to the correct event class.""" diff --git a/backend/app/core/sa_event_store.py b/backend/app/core/sa_event_store.py new file mode 100644 index 000000000..95a94b5c0 --- /dev/null +++ b/backend/app/core/sa_event_store.py @@ -0,0 +1,117 @@ +"""This module implements a SQLAlchemy-backed Event Store.""" +from datetime import datetime, timezone +import importlib +import uuid + +from sqlalchemy import String, Integer, DateTime, JSON, func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, mapped_column, Mapped + +from app.core.db import Base +from app.core.interfaces import Identity +from .event_store import (AppendOnlyStoreConcurrencyException, DomainEvent, + DomainEventStream) + + +class EventStreamEntry(Base): + """SQLAlchemy model representing a row entry in the event_streams table.""" + + __tablename__ = 'event_streams' + + # Primary key: composite (stream_id, stream_version) + stream_id: Mapped[str] = mapped_column(String(36), + primary_key=True, + default=lambda: str(uuid.uuid4())) + stream_version: Mapped[int] = mapped_column(Integer, primary_key=True) + + # Event data and meta data columns + event_data: Mapped[dict] = mapped_column(JSON, nullable=False) + meta_data: Mapped[dict] = mapped_column(JSON, nullable=True) + + # Timestamp of when the event was stored + stored_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), + nullable=False, + default=func.now) + + def __repr__(self): + """Representation of this object as a string.""" + return ( + f"") + + +class SqlAlchemyEventStore: + """Implementation of an Event Store backed by SQLAlchemy.""" + + def __init__(self, session: Session): + """Instantiate the Event Store using a SQLAlchemy session.""" + if session is None: + raise ValueError("A Session is required to construct this Event Store.") + + self.session = session + + def fetch(self, stream_id: Identity) -> DomainEventStream: + """Fetch the event stream for the given stream.""" + stream = DomainEventStream(version=0, events=[]) + + stream_entries = self.session.execute( + select(EventStreamEntry.stream_version, + EventStreamEntry.event_data)).all() + + for stream_version, event_data in stream_entries: + stream.version = stream_version + stream.events.append( + self._deserialize_event(event_data)) + + return stream + + def append(self, stream_id: Identity, new_events: list[DomainEvent], + expected_version: int): + """Append list of events for the given stream. + + An AppendOnlyStoreConcurrencyException is raised when the given + expected version is not the last version found in the database for + the given stream. This means that another process has already + updated the stream's events. + """ + statement = select(func.max( + EventStreamEntry.stream_version)).filter_by( + stream_id=str(stream_id)) + version = self.session.scalars(statement).one_or_none() + + if version is None: + version = 0 + if version != expected_version: + raise AppendOnlyStoreConcurrencyException( + f"version={version}, expected={expected_version}, stream_id={stream_id}" + ) + + stream_entries = [ + EventStreamEntry( + stream_id=str(stream_id), + stream_version=version + inc, + event_data=e.to_dict(), + meta_data={}, + stored_at=datetime.now(tz=timezone.utc), + ) for inc, e in enumerate(new_events, start=1) + ] + + self.session.add_all(stream_entries) + try: + self.session.commit() + except IntegrityError: + self.session.rollback() + raise ValueError( + "Failed to append events due to database integrity error (likely a version conflict)." + ) + + def _deserialize_event(self, event_data): + """Convert a dictionary back to the correct event class.""" + fully_qualified_type = event_data["type"] + module_name, class_name = fully_qualified_type.rsplit(".", 1) + + # Dynamically import the module and get the class + module = importlib.import_module(module_name) + event_class = getattr(module, class_name) + + return event_class.from_dict(event_data["data"]) diff --git a/backend/app/modules/access/invite/contracts.py b/backend/app/modules/access/invite/contracts.py index 154109924..b04eb710f 100644 --- a/backend/app/modules/access/invite/contracts.py +++ b/backend/app/modules/access/invite/contracts.py @@ -24,32 +24,44 @@ class SendInviteCommand(DomainCommand): full_name: str email: str invitee_role: UserRoleEnum - inviter_id: UserId + inviter_id: str inviter_role: UserRoleEnum sent_at: datetime @dataclass -class InviteSentDomainEvent(DomainEvent): +class SendInviteRequestedDomainEvent(DomainEvent): """An Invite domain event.""" - full_name: str email: str + full_name: str invitee_role: UserRoleEnum - inviter_id: UserId + inviter_id: str inviter_role: UserRoleEnum sent_at: datetime + expire_at: datetime @classmethod def from_dict(cls, data: dict[str, Any]): """Deserialize from dict to the correct event class.""" sent_at = datetime.fromisoformat(data['sent_at']) + expire_at = datetime.fromisoformat(data['expire_at']) return cls(full_name=data['full_name'], email=data['email'], invitee_role=data['invitee_role'], inviter_id=data['inviter_id'], - inviter_role=data['inviter_role'] - sent_at=sent_at) + inviter_role=data['inviter_role'], + sent_at=sent_at, + expire_at=expire_at) + + +@dataclass +class InviteSentDomainEvent(DomainEvent): + """An Invite domain event.""" + + email: str + full_name: str + expire_at: datetime @dataclass @@ -71,7 +83,7 @@ class InviteAlreadySentException(Exception): pass -class UninvitedException(Exception): +class NotInvitedException(Exception): """An invite was accepted without an invitation.""" pass diff --git a/backend/app/modules/access/invite/invite_aggregate.py b/backend/app/modules/access/invite/invite_aggregate.py index c4c4fa2f0..bcf1f62a2 100644 --- a/backend/app/modules/access/invite/invite_aggregate.py +++ b/backend/app/modules/access/invite/invite_aggregate.py @@ -3,10 +3,11 @@ from ..schemas import UserRoleEnum from .contracts import ( InviteId, + SendInviteRequestedDomainEvent, InviteSentDomainEvent, InviteAcceptedDomainEvent, InviteAlreadySentException, - UninvitedException, + NotInvitedException, ) from collections.abc import Callable @@ -19,11 +20,13 @@ class InviteState: email: InviteId = None full_name: str = None invitee_role: UserRoleEnum = None - inviter_id: UserId = None + inviter_id: str = None inviter_role: UserRoleEnum = None sent_at: datetime = None expire_at: datetime = None accepted_at: datetime = None + pending_send_invite: bool = False + invited: bool = False def __init__(self, domain_events: list[DomainEvent]): """Initialize state from given events.""" @@ -34,13 +37,20 @@ def mutate(self, domain_event: DomainEvent): """Update the state based on the domain event.""" getattr(self, 'when_' + domain_event.__class__.__name__)(domain_event) - def when_InviteSentDomainEvent(self, event: InviteSentDomainEvent): + def when_SendInviteRequestedDomainEvent( + self, event: SendInviteRequestedDomainEvent): """Update the state of an Invite.""" - self.full_name = event.full_name self.email = event.email + self.full_name = event.full_name self.invitee_role = event.invitee_role - self.sent_by = event.sent_by + self.inviter_id = event.inviter_id + self.inviter_role = event.inviter_role self.sent_at = event.sent_at + + self.pending_send_invite = True + + def when_InviteSentDomainEvent(self, event: InviteSentDomainEvent): + """Update the state of an Invite.""" self.invited = True def when_InviteAcceptedDomainEvent(self, event: InviteAcceptedDomainEvent): @@ -65,26 +75,35 @@ def changes(self): """See a view into the events that cause state changes.""" return self._changes - def send_invite(self, full_name: str, email: str, invitee_role: str, - sent_by: str, sent_at: datetime, token_gen: Callable[[str], - str]): + def send_invite(self, full_name: str, email: str, + invitee_role: UserRoleEnum, inviter_id: str, + inviter_role: UserRoleEnum, sent_at: datetime, + expire_policy: Callable[[datetime], datetime]): """Send an invite to the given recipient.""" - if self._state.invited: + if self._state.pending_send_invite or self._state.invited: raise InviteAlreadySentException(email) - e = InviteSentDomainEvent(full_name=full_name, - email=email, - invitee_role=invitee_role, - sent_by=sent_by, - sent_at=sent_at, - token=token_gen(email)) + e = SendInviteRequestedDomainEvent(email=email, + full_name=full_name, + invitee_role=invitee_role, + inviter_id=inviter_id, + inviter_role=inviter_role, + sent_at=sent_at, + expire_at=expire_policy(sent_at)) + self._apply(e) + + def process_sent_invite(self, email: str): + """Process a sent invite.""" + e = InviteSentDomainEvent(email, self._state.full_name, + self._state.expire_at) + self._apply(e) - def accept_invite(self, email: str, token: str): + def accept_invite(self, email: str, accepted_at: datetime): """Accept an invite.""" if not self._state.invited: - raise UninvitedException(f"{email} was not invited.") + raise NotInvitedException(f"{email} was not invited.") - e = InviteAcceptedDomainEvent(email=email, token=token) + e = InviteAcceptedDomainEvent(email=email, accepted_at=accepted_at) self._apply(e) diff --git a/backend/app/modules/access/models.py b/backend/app/modules/access/models.py index 5e27ce92c..d83fcff1e 100644 --- a/backend/app/modules/access/models.py +++ b/backend/app/modules/access/models.py @@ -12,6 +12,8 @@ from app.core.db import Base from app.core.interfaces import ValueObject +from dataclasses import dataclass + class User(Base): __tablename__ = "user" @@ -39,18 +41,46 @@ class Role(Base): users = relationship("User", back_populates="role") -class Email(ValueObject): + +class InvalidEmailError(Exception): + pass + + +@dataclass(frozen=True) +class EmailAddress(ValueObject): """Represent a valid email address.""" - def __init__(self, email: str): + email: str + + def __post_init__(self): + try: + validate_email(self.email, + check_deliverability=False, + allow_quoted_local=True) + + except EmailNotValidError as e: + raise InvalidEmailError(e) + + @classmethod + def from_str(cls, email: str) -> "EmailAddress": try: - emailinfo = validate_email(email, check_deliverability=False) + emailinfo = validate_email(email, + check_deliverability=False, + allow_quoted_local=True) - self.email = emailinfo.normalized + return cls(emailinfo.normalized) except EmailNotValidError as e: - raise e + raise InvalidEmailError(e) + + def __eq__(self, o): + if self is o: + return True + + if str(o) == self.email: + return True + + return False - @property - def email(self): + def __str__(self): return self.email diff --git a/backend/tests/unit/access/test_email_value_object.py b/backend/tests/unit/access/test_email_value_object.py new file mode 100644 index 000000000..295edac83 --- /dev/null +++ b/backend/tests/unit/access/test_email_value_object.py @@ -0,0 +1,39 @@ +from app.modules.access.models import EmailAddress, InvalidEmailError + +import dataclasses +import pytest + + +@pytest.mark.parametrize( + "test_input,expected", + [("test@test.com", "test@test.com"), + ("test@example.com", "test@example.com"), + ("test+folder@example.co.uk", "test+folder@example.co.uk"), + ("δοκιμή@παράδειγμα.δοκιμή", "δοκιμή@παράδειγμα.δοκιμή"), + ("二ノ宮@黒川.日本", "二ノ宮@黒川.日本"), + ("long.email-address-with-hyphens@and.subdomains.example.com", + "long.email-address-with-hyphens@and.subdomains.example.com"), + ('"john..doe"@example.org', '"john..doe"@example.org'), + ("I❤️CHOCOLATE@example.com", "I❤️CHOCOLATE@example.com")]) +def test_email_address_value_object(test_input, expected): + email = EmailAddress.from_str(test_input) + assert expected == str(email) + assert expected == email.email + assert expected == email + + +@pytest.mark.parametrize("test_input", [ + ("test@"), ("abc.example.com"), ("a@b@c@example.com"), + ('"My Name" '), ('this is"not\\allowed@example.com'), + ("1234567890123456789012345678901234567890123456789012345678901234+x@example.com" + ) +]) +def test_invalid_email_address(test_input): + with pytest.raises(InvalidEmailError): + EmailAddress.from_str(test_input) + + +def test_immutable(): + email = EmailAddress("test@test.com") + with pytest.raises(dataclasses.FrozenInstanceError): + email.email = "change@test.com" diff --git a/backend/tests/unit/access/test_invite.py b/backend/tests/unit/access/test_invite.py index 6bd43f43d..7b58f0839 100644 --- a/backend/tests/unit/access/test_invite.py +++ b/backend/tests/unit/access/test_invite.py @@ -1,15 +1,18 @@ +from datetime import datetime, timedelta, timezone + +import pytest + from app.modules.access.invite.contracts import ( InviteId, + SendInviteRequestedDomainEvent, InviteSentDomainEvent, InviteAcceptedDomainEvent, InviteAlreadySentException, - UninvitedException, + NotInvitedException, ) from app.modules.access.invite.invite_aggregate import Invite +from app.modules.access.schemas import UserRoleEnum -from datetime import datetime, timezone - -import pytest def then(given, when, expected_events): @@ -17,7 +20,7 @@ def then(given, when, expected_events): when(invite) assert expected_events == invite.changes - assert invite._state.invited + assert invite._state.pending_send_invite or invite._state.invited def thenException(given, when, expected_exception_class): @@ -33,10 +36,12 @@ def test_send_invite(): fixed_datetime = datetime(2020, 6, 15, 12, 0, 0, tzinfo=timezone.utc) full_name = 'test name' email = 'test@email.com' - invitee_role = 'Coordinator' - sent_by = 'coordinators' + invitee_role = UserRoleEnum.GUEST + inviter_id = 'coordinator-id' + inviter_role = UserRoleEnum.COORDINATOR sent_at = fixed_datetime + expire_policy = lambda sent_at: sent_at + timedelta(days=7) token_gen = lambda email: 'token-' + email # Given @@ -46,50 +51,91 @@ def test_send_invite(): when = lambda invite: invite.send_invite(full_name=full_name, email=email, invitee_role=invitee_role, - sent_by=sent_by, + inviter_id=inviter_id, + inviter_role=inviter_role, sent_at=sent_at, - token_gen=token_gen) + expire_policy=expire_policy) # Then expected_events = [ - InviteSentDomainEvent(full_name=full_name, - email=email, - invitee_role=invitee_role, - sent_by=sent_by, - sent_at=sent_at, - token=token_gen(email)) + SendInviteRequestedDomainEvent(full_name=full_name, + email=email, + invitee_role=invitee_role, + inviter_id=inviter_id, + inviter_role=inviter_role, + sent_at=sent_at, + expire_at=expire_policy(sent_at)) ] then(given, when, expected_events) -def test_send_duplicate_invite(): +def test_send_duplicate_pending_invite(): # Setup + fixed_datetime = datetime(2020, 6, 15, 12, 0, 0, tzinfo=timezone.utc) + email = 'test@email.com' full_name = 'test name' + invitee_role = UserRoleEnum.COORDINATOR + inviter_id = 'coordinator-id' + inviter_role = UserRoleEnum.COORDINATOR + sent_at = fixed_datetime + expire_at = fixed_datetime + timedelta(days=7) + + expire_policy = lambda sent_at: expire_at + + # Given + given = [ + SendInviteRequestedDomainEvent(full_name=full_name, + email=email, + invitee_role=invitee_role, + inviter_id=inviter_id, + inviter_role=inviter_role, + sent_at=sent_at, + expire_at=expire_at) + ] + + # When + when = lambda invite: invite.send_invite(full_name=full_name, + email=email, + invitee_role=invitee_role, + inviter_id=inviter_id, + inviter_role=inviter_role, + sent_at=sent_at, + expire_policy=expire_policy) + + # Then + thenException(given, when, InviteAlreadySentException) + + +def test_send_duplicate_sent_invite(): + + # Setup + fixed_datetime = datetime(2020, 6, 15, 12, 0, 0, tzinfo=timezone.utc) email = 'test@email.com' - invitee_role = 'Coordinator' - sent_by = 'coordinators' + full_name = 'test name' + invitee_role = UserRoleEnum.COORDINATOR + inviter_id = 'coordinator-id' + inviter_role = UserRoleEnum.COORDINATOR + sent_at = fixed_datetime + expire_at = fixed_datetime + timedelta(days=7) - token_gen = lambda email: email + expire_policy = lambda sent_at: expire_at # Given given = [ - InviteSentDomainEvent(full_name=full_name, - email=email, - invitee_role=invitee_role, - sent_by=sent_by, - sent_at=datetime.now(timezone.utc), - token=token_gen(email)) + InviteSentDomainEvent(email=email, + full_name=full_name, + expire_at=expire_at) ] # When when = lambda invite: invite.send_invite(full_name=full_name, email=email, invitee_role=invitee_role, - sent_by=sent_by, - sent_at=datetime.now(tz=timezone. - utc), - token_gen=token_gen) + inviter_id=inviter_id, + inviter_role=inviter_role, + sent_at=sent_at, + expire_policy=expire_policy) # Then thenException(given, when, InviteAlreadySentException) @@ -98,30 +144,25 @@ def test_send_duplicate_invite(): def test_invite_accepted(): # Setup - full_name = 'test name' email = 'test@email.com' - invitee_role = 'Coordinator' - sent_by = 'coordinators' - - token_gen = lambda email: email + full_name = 'test name' + expire_at = datetime.now(timezone.utc) + timedelta(days=7) + accepted_at = datetime.now(timezone.utc) # Given given = [ - InviteSentDomainEvent(full_name=full_name, - email=email, - invitee_role=invitee_role, - sent_by=sent_by, - sent_at=datetime.now(tz=timezone.utc), - token=token_gen(email)) + InviteSentDomainEvent(email=email, + full_name=full_name, + expire_at=expire_at) ] # When when = lambda invite: invite.accept_invite(email=email, - token=token_gen(email)) + accepted_at=accepted_at) # Then expected_events = [ - InviteAcceptedDomainEvent(email=email, token=token_gen(email)) + InviteAcceptedDomainEvent(email=email, accepted_at=accepted_at) ] then(given, when, expected_events) @@ -129,19 +170,15 @@ def test_invite_accepted(): def test_uninvited_invite_accepted(): # Setup - full_name = 'test name' email = 'test@email.com' - invitee_role = 'Coordinator' - sent_by = 'coordinators' - - token_gen = lambda email: email + accepted_at = datetime.now(timezone.utc) # Given given = [] # When when = lambda invite: invite.accept_invite(email=email, - token=token_gen(email)) + accepted_at=datetime) # Then - thenException(given, when, UninvitedException) + thenException(given, when, NotInvitedException) diff --git a/backend/tests/unit/core/test_event_store.py b/backend/tests/unit/core/test_event_store.py index c3f26df74..7a90a9395 100644 --- a/backend/tests/unit/core/test_event_store.py +++ b/backend/tests/unit/core/test_event_store.py @@ -1,8 +1,9 @@ -from app.core.event_store import DomainEvent, InMemoryEventStore - from dataclasses import dataclass from typing import Any +from app.core.event_store import DomainEvent, InMemoryEventStore +from app.core.sa_event_store import SqlAlchemyEventStore + @dataclass(frozen=True) class Event1(DomainEvent): @@ -43,3 +44,33 @@ def test_in_memory_event_store(): assert len(event_stream.events) == 4 assert event_stream.version == 4 assert event_stream.events == events + events + + +def test_sqlalchemy_event_store(session_factory): + events = [Event1("x"), Event2(1)] + stream_id = "test-stream" + + with session_factory() as session: + event_store = SqlAlchemyEventStore(session) + + event_stream = event_store.fetch(stream_id) + assert len(event_stream.events) == 0 + assert event_stream.version == 0 + + with session_factory() as session: + event_store = SqlAlchemyEventStore(session) + + event_store.append(stream_id, events, event_stream.version) + event_stream = event_store.fetch(stream_id) + assert len(event_stream.events) == 2 + assert event_stream.version == 2 + assert event_stream.events == events + + with session_factory() as session: + event_store = SqlAlchemyEventStore(session) + + event_store.append(stream_id, events, event_stream.version) + event_stream = event_store.fetch(stream_id) + assert len(event_stream.events) == 4 + assert event_stream.version == 4 + assert event_stream.events == events + events From 54183c2f415ed7fa8988c3baf4e9a4bf9f8df165 Mon Sep 17 00:00:00 2001 From: "Mr. Paul" Date: Thu, 24 Oct 2024 17:05:53 -0700 Subject: [PATCH 6/8] Event Store fetch events by id and ordered by version --- backend/app/core/sa_event_store.py | 14 ++++---- backend/tests/unit/core/test_event_store.py | 39 +++++++++++++++++---- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/backend/app/core/sa_event_store.py b/backend/app/core/sa_event_store.py index 95a94b5c0..d817710fe 100644 --- a/backend/app/core/sa_event_store.py +++ b/backend/app/core/sa_event_store.py @@ -46,7 +46,8 @@ class SqlAlchemyEventStore: def __init__(self, session: Session): """Instantiate the Event Store using a SQLAlchemy session.""" if session is None: - raise ValueError("A Session is required to construct this Event Store.") + raise ValueError( + "A Session is required to construct this Event Store.") self.session = session @@ -54,14 +55,15 @@ def fetch(self, stream_id: Identity) -> DomainEventStream: """Fetch the event stream for the given stream.""" stream = DomainEventStream(version=0, events=[]) - stream_entries = self.session.execute( - select(EventStreamEntry.stream_version, - EventStreamEntry.event_data)).all() + statement = select(EventStreamEntry.stream_version, + EventStreamEntry.event_data).filter_by( + stream_id=stream_id).order_by( + EventStreamEntry.stream_version) + stream_entries = self.session.execute(statement).all() for stream_version, event_data in stream_entries: stream.version = stream_version - stream.events.append( - self._deserialize_event(event_data)) + stream.events.append(self._deserialize_event(event_data)) return stream diff --git a/backend/tests/unit/core/test_event_store.py b/backend/tests/unit/core/test_event_store.py index 7a90a9395..8a2b067eb 100644 --- a/backend/tests/unit/core/test_event_store.py +++ b/backend/tests/unit/core/test_event_store.py @@ -48,29 +48,56 @@ def test_in_memory_event_store(): def test_sqlalchemy_event_store(session_factory): events = [Event1("x"), Event2(1)] - stream_id = "test-stream" + stream_id_1 = "test-stream-1" + stream_id_2 = "test-stream-2" with session_factory() as session: event_store = SqlAlchemyEventStore(session) + event_stream = event_store.fetch(stream_id_1) + assert len(event_stream.events) == 0 + assert event_stream.version == 0 - event_stream = event_store.fetch(stream_id) + with session_factory() as session: + event_store = SqlAlchemyEventStore(session) + event_stream = event_store.fetch(stream_id_2) assert len(event_stream.events) == 0 assert event_stream.version == 0 with session_factory() as session: event_store = SqlAlchemyEventStore(session) + event_store.append(stream_id_1, events, event_stream.version) - event_store.append(stream_id, events, event_stream.version) - event_stream = event_store.fetch(stream_id) + with session_factory() as session: + event_store = SqlAlchemyEventStore(session) + event_store.append(stream_id_2, events[::-1], event_stream.version) + + with session_factory() as session: + event_store = SqlAlchemyEventStore(session) + event_stream = event_store.fetch(stream_id_1) assert len(event_stream.events) == 2 assert event_stream.version == 2 assert event_stream.events == events with session_factory() as session: event_store = SqlAlchemyEventStore(session) + event_store.append(stream_id_1, events, event_stream.version) - event_store.append(stream_id, events, event_stream.version) - event_stream = event_store.fetch(stream_id) + with session_factory() as session: + event_store = SqlAlchemyEventStore(session) + event_stream = event_store.fetch(stream_id_1) assert len(event_stream.events) == 4 assert event_stream.version == 4 assert event_stream.events == events + events + + with session_factory() as session: + event_store = SqlAlchemyEventStore(session) + event_stream = event_store.fetch(stream_id_2) + assert len(event_stream.events) == 2 + assert event_stream.version == 2 + assert event_stream.events == events[::-1] + + with session_factory() as session: + event_store = SqlAlchemyEventStore(session) + event_stream = event_store.fetch('Non-existing ID') + assert len(event_stream.events) == 0 + assert event_stream.version == 0 From 36503af496ed4bbb8d5d29f2533f611ade7dba5b Mon Sep 17 00:00:00 2001 From: "Mr. Paul" Date: Thu, 7 Nov 2024 18:24:58 -0800 Subject: [PATCH 7/8] Implementation of three invite slices --- backend/alembic/env.py | 4 +- .../281f96d4d453_dashboard_users_view.py | 46 +++ ...6ce898b0e45e_dashboard_users_read_model.py | 37 ++ .../a1a53aaf81d3_initial_migration.py | 2 +- ...b5_refactor_users_and_roles_tables_add_.py | 133 +++++++ backend/app/core/event_store.py | 15 +- backend/app/core/interfaces.py | 69 +++- backend/app/core/message_bus.py | 32 ++ backend/app/core/sa_event_store.py | 84 +++-- backend/app/health.py | 2 +- backend/app/main.py | 44 ++- backend/app/modules/access/auth_controller.py | 357 ++++++++---------- backend/app/modules/access/crud.py | 6 +- .../app/modules/access/hosts_controller.py | 4 +- .../access/invite/application_service.py | 49 +++ .../app/modules/access/invite/contracts.py | 104 +++-- .../modules/access/invite/invite_aggregate.py | 108 ++++-- .../app/modules/access/invite/processor.py | 88 +++++ backend/app/modules/access/models.py | 140 +++++-- backend/app/modules/access/schemas.py | 78 ++-- backend/app/modules/access/user_repo.py | 57 ++- backend/app/modules/access/user_roles.py | 8 - backend/app/modules/deps.py | 140 ++++++- .../modules/intake_profile/forms/models.py | 4 +- .../coordinator/coordinator_dashboard.py | 87 ++--- .../dashboards/coordinator/schemas.py | 9 - backend/app/modules/workflow/models.py | 29 -- .../modules/workflow/unmatched_guest_case.py | 38 -- .../app/projections/dashboard_users_view.py | 83 ++++ backend/app/seed.py | 8 +- .../startup_scripts/create_groups_users.py | 45 ++- backend/tests/unit/access/test_invite.py | 132 +++++-- backend/tests/unit/core/test_event_store.py | 40 +- 33 files changed, 1419 insertions(+), 663 deletions(-) create mode 100644 backend/alembic/versions/281f96d4d453_dashboard_users_view.py create mode 100644 backend/alembic/versions/6ce898b0e45e_dashboard_users_read_model.py create mode 100644 backend/alembic/versions/fead8af85db5_refactor_users_and_roles_tables_add_.py create mode 100644 backend/app/core/message_bus.py create mode 100644 backend/app/modules/access/invite/application_service.py create mode 100644 backend/app/modules/access/invite/processor.py delete mode 100644 backend/app/modules/access/user_roles.py delete mode 100644 backend/app/modules/workflow/dashboards/coordinator/schemas.py delete mode 100644 backend/app/modules/workflow/models.py delete mode 100644 backend/app/modules/workflow/unmatched_guest_case.py create mode 100644 backend/app/projections/dashboard_users_view.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 7ade5d636..f720b07fa 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -8,7 +8,9 @@ import app.modules.matching.models import app.modules.relationship_management.models import app.modules.tenant_housing_orgs.models -import app.modules.workflow.models +# import app.modules.workflow.models +import app.projections.dashboard_users_view +import app.core.sa_event_store from logging.config import fileConfig diff --git a/backend/alembic/versions/281f96d4d453_dashboard_users_view.py b/backend/alembic/versions/281f96d4d453_dashboard_users_view.py new file mode 100644 index 000000000..5a61db36e --- /dev/null +++ b/backend/alembic/versions/281f96d4d453_dashboard_users_view.py @@ -0,0 +1,46 @@ +"""Dashboard users view. + +Revision ID: 281f96d4d453 +Revises: fead8af85db5 +Create Date: 2024-11-04 20:58:09.765444 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '281f96d4d453' +down_revision = 'fead8af85db5' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('dashboard_users_view', sa.Column('user_id', sa.String(), nullable=False)) + op.add_column('dashboard_users_view', sa.Column('name', sa.String(), nullable=False)) + op.add_column('dashboard_users_view', sa.Column('status', sa.String(), nullable=False)) + op.add_column('dashboard_users_view', sa.Column('coordinator_name', sa.String(), nullable=False)) + op.add_column('dashboard_users_view', sa.Column('updated', sa.DateTime(), nullable=False)) + op.drop_column('dashboard_users_view', 'id') + op.drop_column('dashboard_users_view', 'caseStatus') + op.drop_column('dashboard_users_view', 'userName') + op.drop_column('dashboard_users_view', 'lastUpdated') + op.drop_column('dashboard_users_view', 'coordinatorName') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('dashboard_users_view', sa.Column('coordinatorName', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.add_column('dashboard_users_view', sa.Column('lastUpdated', postgresql.TIMESTAMP(), autoincrement=False, nullable=False)) + op.add_column('dashboard_users_view', sa.Column('userName', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.add_column('dashboard_users_view', sa.Column('caseStatus', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.add_column('dashboard_users_view', sa.Column('id', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.drop_column('dashboard_users_view', 'updated') + op.drop_column('dashboard_users_view', 'coordinator_name') + op.drop_column('dashboard_users_view', 'status') + op.drop_column('dashboard_users_view', 'name') + op.drop_column('dashboard_users_view', 'user_id') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/6ce898b0e45e_dashboard_users_read_model.py b/backend/alembic/versions/6ce898b0e45e_dashboard_users_read_model.py new file mode 100644 index 000000000..5979db9fd --- /dev/null +++ b/backend/alembic/versions/6ce898b0e45e_dashboard_users_read_model.py @@ -0,0 +1,37 @@ +"""Dashboard users read model. + +Revision ID: 6ce898b0e45e +Revises: 281f96d4d453 +Create Date: 2024-11-04 21:19:21.415171 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '6ce898b0e45e' +down_revision = '281f96d4d453' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('dashboard_users_view') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('dashboard_users_view', + sa.Column('email', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('role', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('notes', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('user_id', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('status', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('coordinator_name', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('updated', postgresql.TIMESTAMP(), autoincrement=False, nullable=False) + ) + # ### end Alembic commands ### diff --git a/backend/alembic/versions/a1a53aaf81d3_initial_migration.py b/backend/alembic/versions/a1a53aaf81d3_initial_migration.py index 8bf0a8070..996a34655 100644 --- a/backend/alembic/versions/a1a53aaf81d3_initial_migration.py +++ b/backend/alembic/versions/a1a53aaf81d3_initial_migration.py @@ -37,7 +37,7 @@ def upgrade() -> None: sa.Column('description', sa.String(), nullable=False), sa.Column('created_at', sa.DateTime(timezone=True), - server_default=sa.sql.func.utcnow(), + server_default=sa.func.timezone('UTC', sa.func.now()), nullable=False), sa.PrimaryKeyConstraint('form_id')) op.create_table('housing_orgs', sa.Column('housing_org_id', sa.Integer(), nullable=False), diff --git a/backend/alembic/versions/fead8af85db5_refactor_users_and_roles_tables_add_.py b/backend/alembic/versions/fead8af85db5_refactor_users_and_roles_tables_add_.py new file mode 100644 index 000000000..a4e08f013 --- /dev/null +++ b/backend/alembic/versions/fead8af85db5_refactor_users_and_roles_tables_add_.py @@ -0,0 +1,133 @@ +"""Refactor users and roles tables. Add event store. + +Revision ID: fead8af85db5 +Revises: a1a53aaf81d3 +Create Date: 2024-11-04 19:44:58.247093 + +""" +from alembic import op +import sqlalchemy as sa +import app + + +# revision identifiers, used by Alembic. +revision = 'fead8af85db5' +down_revision = 'a1a53aaf81d3' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('dashboard_users_view', + sa.Column('id', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('userName', sa.String(), nullable=False), + sa.Column('role', sa.String(), nullable=False), + sa.Column('caseStatus', sa.String(), nullable=False), + sa.Column('coordinatorName', sa.String(), nullable=False), + sa.Column('lastUpdated', sa.DateTime(), nullable=False), + sa.Column('notes', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id', 'email') + ) + op.create_table('event_streams', + sa.Column('stream_id', sa.String(length=36), nullable=False), + sa.Column('stream_version', sa.Integer(), nullable=False), + sa.Column('event_data', sa.JSON(), nullable=False), + sa.Column('meta_data', sa.JSON(), nullable=True), + sa.Column('stored_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('stream_id', 'stream_version') + ) + op.create_table('roles', + sa.Column('role', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('role') + ) + op.create_table('users', + sa.Column('user_id', app.modules.access.models.UserIdType(), nullable=False), + sa.Column('email', app.modules.access.models.EmailAddressType(), nullable=False), + sa.Column('first_name', sa.String(), nullable=False), + sa.Column('middle_name', sa.String(), nullable=True), + sa.Column('last_name', sa.String(), nullable=False), + sa.Column('role', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['role'], ['roles.role'], ), + sa.PrimaryKeyConstraint('user_id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.drop_constraint('user_roleId_fkey', 'user', 'foreignkey') + op.drop_constraint('responses_user_id_fkey', 'responses', 'foreignkey') + op.drop_constraint('unmatched_guest_case_status_id_fkey', 'unmatched_guest_case', 'foreignkey') + op.drop_constraint('unmatched_guest_case_guest_id_fkey', 'unmatched_guest_case', 'foreignkey') + op.drop_constraint('unmatched_guest_case_coordinator_id_fkey', 'unmatched_guest_case', 'foreignkey') + op.drop_index('ix_role_id', table_name='role') + op.drop_table('role') + op.drop_table('unmatched_guest_case_status') + op.drop_index('ix_user_id', table_name='user') + op.drop_table('user') + op.drop_table('unmatched_guest_case') + op.alter_column('responses', 'user_id', new_column_name='old_user_id') + op.add_column('responses', + sa.Column('user_id', app.modules.access.models.UserIdType(), nullable=False)) + op.drop_column('responses', 'old_user_id') + op.create_foreign_key(None, 'responses', 'users', ['user_id'], ['user_id']) + + op.execute( + "INSERT INTO roles (role) VALUES ('admin') ON CONFLICT DO NOTHING") + op.execute( + "INSERT INTO roles (role) VALUES ('guest') ON CONFLICT DO NOTHING") + op.execute( + "INSERT INTO roles (role) VALUES ('coordinator') ON CONFLICT DO NOTHING" + ) + op.execute( + "INSERT INTO roles (role) VALUES ('host') ON CONFLICT DO NOTHING") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'responses', type_='foreignkey') + op.create_foreign_key('responses_user_id_fkey', 'responses', 'user', ['user_id'], ['id']) + op.alter_column('responses', 'user_id', + existing_type=app.modules.access.models.UserIdType(), + type_=sa.INTEGER(), + existing_nullable=False) + op.create_table('unmatched_guest_case', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('guest_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('coordinator_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('status_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['coordinator_id'], ['user.id'], name='unmatched_guest_case_coordinator_id_fkey'), + sa.ForeignKeyConstraint(['guest_id'], ['user.id'], name='unmatched_guest_case_guest_id_fkey'), + sa.ForeignKeyConstraint(['status_id'], ['unmatched_guest_case_status.id'], name='unmatched_guest_case_status_id_fkey'), + sa.PrimaryKeyConstraint('id', name='unmatched_guest_case_pkey') + ) + op.create_table('user', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('email', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('firstName', sa.VARCHAR(length=255), autoincrement=False, nullable=False), + sa.Column('middleName', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('lastName', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('roleId', sa.INTEGER(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['roleId'], ['role.id'], name='user_roleId_fkey'), + sa.PrimaryKeyConstraint('id', name='user_pkey'), + sa.UniqueConstraint('email', name='user_email_key') + ) + op.create_index('ix_user_id', 'user', ['id'], unique=False) + op.create_table('unmatched_guest_case_status', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('status_text', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name='unmatched_guest_case_status_pkey'), + sa.UniqueConstraint('status_text', name='unmatched_guest_case_status_status_text_key') + ) + op.create_table('role', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('type', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name='role_pkey'), + sa.UniqueConstraint('type', name='role_type_key') + ) + op.create_index('ix_role_id', 'role', ['id'], unique=False) + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + op.drop_table('roles') + op.drop_table('event_streams') + op.drop_table('dashboard_users_view') + # ### end Alembic commands ### diff --git a/backend/app/core/event_store.py b/backend/app/core/event_store.py index 808e0c8fd..dcfc7e381 100644 --- a/backend/app/core/event_store.py +++ b/backend/app/core/event_store.py @@ -7,15 +7,22 @@ from .interfaces import Identity, DomainEvent + class AppendOnlyStoreConcurrencyException(Exception): pass +@dataclass +class DomainEventStream: + version: int + events: list[DomainEvent] + + class EventStore(Protocol): """Abstraction for something that stores domain events.""" @abstractmethod - def fetch(self, stream_id: Identity) -> list[DomainEvent]: + def fetch(self, stream_id: Identity) -> DomainEventStream: raise NotImplementedError @abstractmethod @@ -24,12 +31,6 @@ def append(self, stream_id: Identity, new_events: list[DomainEvent], raise NotImplementedError -@dataclass -class DomainEventStream: - version: int - events: list[DomainEvent] - - class InMemoryEventStore: @dataclass diff --git a/backend/app/core/interfaces.py b/backend/app/core/interfaces.py index 2346b3851..ffff864b6 100644 --- a/backend/app/core/interfaces.py +++ b/backend/app/core/interfaces.py @@ -3,7 +3,11 @@ The classes defined here are used by domain classes to help identify the general responsibility that they represent. """ -from typing import Any +from dataclasses import dataclass, asdict, replace, fields +from datetime import datetime, date, time +import decimal +from typing import Any, Type, TypeVar +import uuid class Identity: @@ -41,6 +45,9 @@ class DomainCommand: """ +T = TypeVar("T", bound="DomainEvent") + + class DomainEvent: """Represent a domain event. @@ -63,7 +70,65 @@ class ExampleEvent(DomainEvent): def from_dict(cls, data: dict[str, Any]): return cls(example_property=int(data['example_property'])) """ + data = asdict(self) # Converts dataclass fields to a dictionary + + # Convert types that aren't JSON serializable + for key, value in data.items(): + data[key] = self._custom_serializer(value) + return { 'type': f"{self.__class__.__module__}.{self.__class__.__name__}", - 'data': self.__dict__, + 'data': data, } + + @classmethod + def from_dict(cls: Type[T], data: dict[str, Any]) -> T: + """Reconstruct an event from a dictionary.""" + init_data = data + + # Convert fields back from strings if necessary + for field in fields(cls): + field_name = field.name + field_type = field.type + if field_name in init_data: + value = init_data[field_name] + init_data[field_name] = cls._custom_deserializer( + value, field_type) + + return cls(**init_data) # Automatically match the dataclass fields + + def _custom_serializer(self, obj): + """Serialize certain Python types.""" + if isinstance(obj, (datetime, date, time)): + return obj.isoformat() + elif isinstance(obj, uuid.UUID): + return str(obj) + elif isinstance(obj, (set, frozenset)): + return list(obj) + elif isinstance(obj, decimal.Decimal): + return float(obj) + elif isinstance(obj, complex): + return [obj.real, obj.imag] + elif isinstance(obj, bytes): + return obj.hex() + else: + return obj + + @classmethod + def _custom_deserializer(cls, value, field_type): + """De-serialize certain Python types.""" + if field_type is datetime and isinstance(value, str): + return datetime.fromisoformat(value) + elif field_type is uuid.UUID and isinstance(value, str): + return uuid.UUID(value) + elif field_type in {set, frozenset} and isinstance(value, list): + return field_type(value) + elif field_type is decimal.Decimal and isinstance(value, (float, str)): + return decimal.Decimal(str(value)) + elif field_type is complex and isinstance(value, + list) and len(value) == 2: + return complex(*value) + elif field_type is bytes and isinstance(value, str): + return bytes.fromhex(value) + else: + return value diff --git a/backend/app/core/message_bus.py b/backend/app/core/message_bus.py new file mode 100644 index 000000000..f7eceb4f7 --- /dev/null +++ b/backend/app/core/message_bus.py @@ -0,0 +1,32 @@ +from typing import TypeVar + +from .interfaces import DomainCommand, DomainEvent + + +Message = DomainCommand | DomainEvent + + +def handle(message: Message): + match message: + case DomainCommand(): + handle_command(message) + case DomainEvent(): + handle_event(message) + + +def handle_event(event: DomainEvent): + for handler in EVENT_HANDLERS[type(event)]: + handler.mutate(event) + + +def handle_command(cmd: DomainCommand): + for handler in COMMAND_HANDLERS[type(cmd)]: + handler.execute(cmd) + + +DC = TypeVar('DC') +DE = TypeVar('DE') + +COMMAND_HANDLERS: dict[DC, list[DomainCommand]] = {} + +EVENT_HANDLERS: dict[DE, list[DomainEvent]] = {} diff --git a/backend/app/core/sa_event_store.py b/backend/app/core/sa_event_store.py index d817710fe..e103d9b32 100644 --- a/backend/app/core/sa_event_store.py +++ b/backend/app/core/sa_event_store.py @@ -1,6 +1,7 @@ """This module implements a SQLAlchemy-backed Event Store.""" from datetime import datetime, timezone import importlib +import json import uuid from sqlalchemy import String, Integer, DateTime, JSON, func, select @@ -9,8 +10,9 @@ from app.core.db import Base from app.core.interfaces import Identity -from .event_store import (AppendOnlyStoreConcurrencyException, DomainEvent, - DomainEventStream) +from app.core.event_store import (AppendOnlyStoreConcurrencyException, + DomainEvent, DomainEventStream) +import app.core.message_bus as message_bus class EventStreamEntry(Base): @@ -43,13 +45,13 @@ def __repr__(self): class SqlAlchemyEventStore: """Implementation of an Event Store backed by SQLAlchemy.""" - def __init__(self, session: Session): - """Instantiate the Event Store using a SQLAlchemy session.""" - if session is None: + def __init__(self, session_factory: Session): + """Instantiate the Event Store using a SQLAlchemy Session factory.""" + if session_factory is None: raise ValueError( - "A Session is required to construct this Event Store.") + "A Session factory is required to construct this Event Store.") - self.session = session + self.session_factory = session_factory def fetch(self, stream_id: Identity) -> DomainEventStream: """Fetch the event stream for the given stream.""" @@ -57,9 +59,10 @@ def fetch(self, stream_id: Identity) -> DomainEventStream: statement = select(EventStreamEntry.stream_version, EventStreamEntry.event_data).filter_by( - stream_id=stream_id).order_by( + stream_id=str(stream_id)).order_by( EventStreamEntry.stream_version) - stream_entries = self.session.execute(statement).all() + with self.session_factory() as session: + stream_entries = session.execute(statement).all() for stream_version, event_data in stream_entries: stream.version = stream_version @@ -76,36 +79,39 @@ def append(self, stream_id: Identity, new_events: list[DomainEvent], the given stream. This means that another process has already updated the stream's events. """ - statement = select(func.max( - EventStreamEntry.stream_version)).filter_by( - stream_id=str(stream_id)) - version = self.session.scalars(statement).one_or_none() - - if version is None: - version = 0 - if version != expected_version: - raise AppendOnlyStoreConcurrencyException( - f"version={version}, expected={expected_version}, stream_id={stream_id}" - ) - - stream_entries = [ - EventStreamEntry( - stream_id=str(stream_id), - stream_version=version + inc, - event_data=e.to_dict(), - meta_data={}, - stored_at=datetime.now(tz=timezone.utc), - ) for inc, e in enumerate(new_events, start=1) - ] - - self.session.add_all(stream_entries) - try: - self.session.commit() - except IntegrityError: - self.session.rollback() - raise ValueError( - "Failed to append events due to database integrity error (likely a version conflict)." - ) + with self.session_factory() as session: + statement = select(func.max( + EventStreamEntry.stream_version)).filter_by( + stream_id=str(stream_id)) + version = session.scalars(statement).one_or_none() + + if version is None: + version = 0 + if version != expected_version: + raise AppendOnlyStoreConcurrencyException( + f"version={version}, expected={expected_version}, stream_id={stream_id}" + ) + + stream_entries = [ + EventStreamEntry( + stream_id=str(stream_id), + stream_version=version + inc, + event_data=e.to_dict(), + meta_data={}, + stored_at=datetime.now(tz=timezone.utc), + ) for inc, e in enumerate(new_events, start=1) + ] + + session.add_all(stream_entries) + try: + session.commit() + for e in new_events: + message_bus.handle(e) + except IntegrityError: + session.rollback() + raise AppendOnlyStoreConcurrencyException( + "Failed to append events due to database integrity error (likely a version conflict)." + ) def _deserialize_event(self, event_data): """Convert a dictionary back to the correct event class.""" diff --git a/backend/app/health.py b/backend/app/health.py index 0c700f09b..c6eb2978b 100644 --- a/backend/app/health.py +++ b/backend/app/health.py @@ -1,4 +1,4 @@ -from backend.app.modules.deps import SettingsDep +from app.modules.deps import SettingsDep from fastapi import APIRouter, status, HTTPException from fastapi.responses import JSONResponse diff --git a/backend/app/main.py b/backend/app/main.py index b2bb0c516..4b8990819 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,10 +1,24 @@ -from fastapi import FastAPI +from datetime import timedelta from contextlib import asynccontextmanager +from fastapi import FastAPI + from .health import health_router from app.modules.router import api_router import app.core.db as db import app.core.config as config +from app.core.sa_event_store import SqlAlchemyEventStore +import app.core.message_bus as message_bus + +from app.modules.deps import get_cognito_client +from app.modules.access.user_repo import UserRepository +from app.modules.access.invite.contracts import ( + SendInviteCommand, ProcessSentInviteCommand, FailedSentInviteCommand, + SendInviteRequestedDomainEvent, InviteSentDomainEvent, + InviteAcceptedDomainEvent, InviteSentFailedDomainEvent) +from app.modules.access.invite.application_service import InviteService +from app.modules.access.invite.processor import CognitoInviteUserProcessor +from app.projections.dashboard_users_view import DashboardUsersProjection @asynccontextmanager @@ -16,6 +30,34 @@ async def lifespan(app: FastAPI): yield +settings = config.get_settings() +engine = db.db_engine(settings) +session_factory = db.db_session_factory(engine) +cognito_client = get_cognito_client(settings) +event_store = SqlAlchemyEventStore(session_factory) +user_repo = UserRepository(session_factory) +invite_service = InviteService( + event_store, + expire_policy=lambda requested_at: requested_at + timedelta(days=7), + user_service=user_repo.get_user_by_id) +dashboard_users_projection = DashboardUsersProjection(session_factory) + +message_bus.COMMAND_HANDLERS = { + SendInviteCommand: [invite_service], + ProcessSentInviteCommand: [invite_service], + FailedSentInviteCommand: [invite_service], +} + +message_bus.EVENT_HANDLERS = { + SendInviteRequestedDomainEvent: [ + dashboard_users_projection, + CognitoInviteUserProcessor(cognito_client, settings), + ], + InviteSentDomainEvent: [dashboard_users_projection], + InviteAcceptedDomainEvent: [], + InviteSentFailedDomainEvent: [dashboard_users_projection], +} + app = FastAPI(lifespan=lifespan) app.include_router(api_router, prefix="/api") diff --git a/backend/app/modules/access/auth_controller.py b/backend/app/modules/access/auth_controller.py index d56553bc9..25a4af7db 100644 --- a/backend/app/modules/access/auth_controller.py +++ b/backend/app/modules/access/auth_controller.py @@ -1,24 +1,31 @@ +from datetime import datetime, timezone import logging -import random import jwt import boto3 +from typing import Annotated - -from fastapi import Depends, APIRouter, HTTPException, Response, Request, Cookie -from fastapi.security import HTTPBearer +from fastapi import Depends, APIRouter, HTTPException, Response, Request +from fastapi.security import OAuth2PasswordRequestForm from fastapi.responses import RedirectResponse, JSONResponse from botocore.exceptions import ClientError, ParamValidationError from app.modules.access.schemas import ( - UserCreate, UserSignInRequest, UserSignInResponse, ForgotPasswordRequest, ConfirmForgotPasswordResponse, - ConfirmForgotPasswordRequest, RefreshTokenResponse, InviteRequest, UserRoleEnum, ConfirmInviteRequest, NewPasswordRequest) -from app.modules.workflow.models import ( UnmatchedGuestCase ) + UserCreate, UserSignInResponse, ForgotPasswordRequest, + ConfirmForgotPasswordResponse, ConfirmForgotPasswordRequest, + RefreshTokenResponse, InviteRequest, InviteResponse, UserRoleEnum, + ConfirmInviteRequest, NewPasswordRequest) +# from app.modules.workflow.models import (UnmatchedGuestCase) from app.modules.access.crud import create_user, delete_user, get_user from app.modules.deps import (SettingsDep, DbSessionDep, CognitoIdpDep, - SecretHashFuncDep, requires_auth, allow_roles, + SecretHashFuncDep, CoordinatorDep, + requires_auth, allow_roles, role_to_cognito_group_map) +from app.modules.access.models import EmailAddress +from app.modules.access.invite.contracts import SendInviteCommand, InviteAlreadySentException, NotInvitedException +import app.core.message_bus as message_bus + router = APIRouter() @@ -30,26 +37,28 @@ def set_session_cookie(response: Response, auth_response: dict): response.set_cookie("refresh_token", refresh_token, httponly=True) response.set_cookie("id_token", id_token, httponly=True) -@router.get('/signup/confirm') -def confirm_sign_up(code: str, email: str, settings: SettingsDep, cognito_client: CognitoIdpDep, calc_secret_hash: SecretHashFuncDep): + +@router.get('/signup/confirm') +def confirm_sign_up(code: str, email: str, settings: SettingsDep, + cognito_client: CognitoIdpDep, + calc_secret_hash: SecretHashFuncDep): secret_hash = calc_secret_hash(email) try: - cognito_client.confirm_sign_up( - ClientId=settings.COGNITO_CLIENT_ID, - SecretHash=secret_hash, - Username=email, - ConfirmationCode=code - ) + cognito_client.confirm_sign_up(ClientId=settings.COGNITO_CLIENT_ID, + SecretHash=secret_hash, + Username=email, + ConfirmationCode=code) - return RedirectResponse(f"{settings.ROOT_URL}/email-verification-success") + return RedirectResponse( + f"{settings.ROOT_URL}/email-verification-success") except Exception as e: - return RedirectResponse(f"{settings.ROOT_URL}/email-verification-error") + return RedirectResponse( + f"{settings.ROOT_URL}/email-verification-error") + @router.post("/signup", description="Sign up a new user") -def signup(body: UserCreate, - settings: SettingsDep, - db: DbSessionDep, +def signup(body: UserCreate, settings: SettingsDep, db: DbSessionDep, cognito_client: CognitoIdpDep, calc_secret_hash: SecretHashFuncDep) -> JSONResponse: @@ -58,7 +67,7 @@ def signup(body: UserCreate, user = create_user(db, body) except Exception as e: raise HTTPException(status_code=400, detail="Failed to create user") - + if user is None: raise HTTPException(status_code=400, detail="User already exists") @@ -85,31 +94,30 @@ def signup(body: UserCreate, ) except Exception as e: cognito_client.admin_delete_user( - UserPoolId=settings.COGNITO_USER_POOL_ID, - Username=user.email - ) + UserPoolId=settings.COGNITO_USER_POOL_ID, Username=user.email) delete_user(db, user.id) raise HTTPException(status_code=400, detail="Failed to confirm user") return JSONResponse(content={"message": "User sign up successful"}) -@router.post("/signin", description="Sign in a user and start a new session", response_model=UserSignInResponse) -def signin(body: UserSignInRequest, - response: Response, - settings: SettingsDep, - db: DbSessionDep, - cognito_client: CognitoIdpDep, - calc_secret_hash: SecretHashFuncDep): - +@router.post("/signin", + description="Sign in a user and start a new session", + response_model=UserSignInResponse) +async def signin(form_data: Annotated[OAuth2PasswordRequestForm, + Depends()], response: Response, + settings: SettingsDep, db: DbSessionDep, + cognito_client: CognitoIdpDep, + calc_secret_hash: SecretHashFuncDep): + try: auth_response = cognito_client.initiate_auth( ClientId=settings.COGNITO_CLIENT_ID, AuthFlow="USER_PASSWORD_AUTH", AuthParameters={ - "USERNAME": body.email, - "PASSWORD": body.password, - "SECRET_HASH": calc_secret_hash(body.email), + "USERNAME": form_data.username, + "PASSWORD": form_data.password, + "SECRET_HASH": calc_secret_hash(form_data.username), }, ) except ClientError as e: @@ -130,32 +138,28 @@ def signin(body: UserSignInRequest, f"{root_url}/create-password?userId={userId}&sessionId={sessionId}" ) - user = get_user(db, body.email) + user = get_user(db, EmailAddress(form_data.username)) if user is None: raise HTTPException(status_code=400, detail="User not found") set_session_cookie(response, auth_response) - return { - "user": user, - "token": auth_response["AuthenticationResult"]["AccessToken"], - } + return UserSignInResponse( + user=user, + access_token=auth_response["AuthenticationResult"]["AccessToken"], + id_token=auth_response["AuthenticationResult"]["IdToken"], + token_type="bearer") -@router.post( - "/signout", dependencies=[ - Depends(HTTPBearer()), - Depends(requires_auth) - ]) + +@router.post("/signout", dependencies=[Depends(requires_auth)]) def signout(request: Request, cognito_client: CognitoIdpDep) -> JSONResponse: access_token = request.headers.get("Authorization").split(" ")[1] - # Signout user - response = cognito_client.global_sign_out( - AccessToken=access_token - ) + response = cognito_client.global_sign_out(AccessToken=access_token) - response = JSONResponse(content={"message": "User signed out successfully"}) + response = JSONResponse( + content={"message": "User signed out successfully"}) # Remove refresh token cookie response.delete_cookie("refresh_token") @@ -165,11 +169,11 @@ def signout(request: Request, cognito_client: CognitoIdpDep) -> JSONResponse: return response -@router.get("/session", description="Get the current session and user info upon page refresh", response_model=UserSignInResponse) -def current_session( - request: Request, - settings: SettingsDep, - db: DbSessionDep, +@router.get( + "/session", + description="Get the current session and user info upon page refresh", + response_model=UserSignInResponse) +def current_session(request: Request, settings: SettingsDep, db: DbSessionDep, cognito_client: CognitoIdpDep, calc_secret_hash: SecretHashFuncDep): @@ -191,9 +195,11 @@ def current_session( ClientId=settings.COGNITO_CLIENT_ID, AuthFlow='REFRESH_TOKEN', AuthParameters={ - 'REFRESH_TOKEN': refresh_token, + 'REFRESH_TOKEN': + refresh_token, # DO NOT CHANGE TO EMAIL. THE REFRESH TOKEN AUTH FLOW REQUIRES the use of the 'cognito:username' instead of email - 'SECRET_HASH': calc_secret_hash(decoded_id_token["cognito:username"]) + 'SECRET_HASH': + calc_secret_hash(decoded_id_token["cognito:username"]) }) except ClientError as e: code = e.response['Error']['Code'] @@ -210,9 +216,10 @@ def current_session( } -@router.get("/refresh", description="Refresh the current access token during session", response_model=RefreshTokenResponse) -def refresh(request: Request, - settings: SettingsDep, +@router.get("/refresh", + description="Refresh the current access token during session", + response_model=RefreshTokenResponse) +def refresh(request: Request, settings: SettingsDep, cognito_client: CognitoIdpDep, calc_secret_hash: SecretHashFuncDep): refresh_token = request.cookies.get('refresh_token') @@ -223,17 +230,19 @@ def refresh(request: Request, detail="Missing refresh token or id token") decoded_id_token = jwt.decode(id_token, - algorithms=["RS256"], - options={"verify_signature": False}) + algorithms=["RS256"], + options={"verify_signature": False}) try: response = cognito_client.initiate_auth( ClientId=settings.COGNITO_CLIENT_ID, AuthFlow='REFRESH_TOKEN', AuthParameters={ - 'REFRESH_TOKEN': refresh_token, + 'REFRESH_TOKEN': + refresh_token, # DO NOT CHANGE TO EMAIL. THE REFRESH TOKEN AUTH FLOW REQUIRES the use of the 'cognito:username' instead of email - 'SECRET_HASH': calc_secret_hash(decoded_id_token["cognito:username"]) + 'SECRET_HASH': + calc_secret_hash(decoded_id_token["cognito:username"]) }) except ClientError as e: code = e.response['Error']['Code'] @@ -251,11 +260,11 @@ def refresh(request: Request, @router.post( - "/forgot-password", - description="Handles forgot password requests by hashing credentials and sending to AWS Cognito", - ) -def forgot_password(body: ForgotPasswordRequest, - settings: SettingsDep, + "/forgot-password", + description= + "Handles forgot password requests by hashing credentials and sending to AWS Cognito", +) +def forgot_password(body: ForgotPasswordRequest, settings: SettingsDep, cognito_client: CognitoIdpDep, calc_secret_hash: SecretHashFuncDep) -> JSONResponse: secret_hash = calc_secret_hash(body.email) @@ -273,17 +282,20 @@ def forgot_password(body: ForgotPasswordRequest, "message": message }) - return JSONResponse(content={"message": "Password reset instructions sent"}) + return JSONResponse( + content={"message": "Password reset instructions sent"}) + +@router.post( + "/forgot-password/confirm", + description= + "Handles forgot password confirmation code requests by receiving the confirmation code and sending to AWS Cognito to verify", + response_model=ConfirmForgotPasswordResponse) +def confirm_forgot_password( + body: ConfirmForgotPasswordRequest, settings: SettingsDep, + cognito_client: CognitoIdpDep, + calc_secret_hash: SecretHashFuncDep) -> JSONResponse: -@router.post("/forgot-password/confirm", - description="Handles forgot password confirmation code requests by receiving the confirmation code and sending to AWS Cognito to verify", - response_model=ConfirmForgotPasswordResponse) -def confirm_forgot_password(body: ConfirmForgotPasswordRequest, - settings: SettingsDep, - cognito_client: CognitoIdpDep, - calc_secret_hash: SecretHashFuncDep) -> JSONResponse: - secret_hash = calc_secret_hash(body.email) try: @@ -305,92 +317,42 @@ def confirm_forgot_password(body: ConfirmForgotPasswordRequest, return {"message": "Password reset successful"} +@router.post( + "/invite", + status_code=202, + description="Invites a new user to join HUU.", +) +def invite(invite_request: InviteRequest, coordinator: CoordinatorDep) -> InviteResponse: + """Invite a new user to join HUU.""" + inviter = coordinator + + cmd = SendInviteCommand(first_name=invite_request.firstName, + middle_name=invite_request.middleName, + last_name=invite_request.lastName, + email=EmailAddress(invite_request.email), + invitee_role=invite_request.role, + inviter_id=inviter.user_id, + inviter_role=UserRoleEnum.COORDINATOR, + requested_at=datetime.now(timezone.utc)) - -@router.post("/invite", - description="Invites a new user to application after creating a new account with user email and a temporary password in AWS Cognito.", - ) -def invite(body: InviteRequest, - request: Request, - settings: SettingsDep, - db: DbSessionDep, - cognito_client: CognitoIdpDep): - - id_token = request.cookies.get('id_token') - refresh_token = request.cookies.get('refresh_token') - - if None in (refresh_token, id_token): - raise HTTPException(status_code=401, - detail="Missing refresh token or id token") - - decoded_id_token = jwt.decode(id_token, - algorithms=["RS256"], - options={"verify_signature": False}) - - coordinator_email = decoded_id_token.get('email') - if not coordinator_email: - raise HTTPException(status_code=401, - detail="Missing 'email' field in the decoded ID token.") - - numbers = '0123456789' - lowercase_chars = 'abcdefghijklmnopqrstuvwxyz' - uppercase_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' - symbols = '.-_~' - temporary_password = ''.join(random.choices(numbers, k=3)) + ''.join(random.choices(lowercase_chars, k=3)) + ''.join(random.choices(symbols, k=1)) + ''.join(random.choices(uppercase_chars, k=3)) - - try: - cognito_client.admin_create_user( - UserPoolId=settings.COGNITO_USER_POOL_ID, - Username=body.email, - TemporaryPassword=temporary_password, - ClientMetadata={ - 'url': settings.ROOT_URL - }, - DesiredDeliveryMediums=["EMAIL"] - ) - - except ClientError as error: - if error.response['Error']['Code'] == 'UserNotFoundException': - raise HTTPException(status_code=400, detail="User not found. Confirmation not sent.") - else: - raise HTTPException(status_code=500, detail=error.response['Error']['Message']) - try: - - user = create_user(db, UserCreate( - role=UserRoleEnum.GUEST, - email=body.email, - firstName=body.firstName, - middleName=body.middleName, - lastName=body.lastName - )) - guest_id = user.id - coordinator = get_user(db, coordinator_email) - if not coordinator: - raise HTTPException(status_code=400, detail="Coordinator not found") - coordinator_id = coordinator.id - - unmatched_case_repo = UnmatchedGuestCase(db) - unmatched_case_repo.add_case( - guest_id=guest_id, - coordinator_id=coordinator_id - ) - except Exception as error: - raise HTTPException(status_code=400, detail=str(error)) - - + message_bus.handle(cmd) + return InviteResponse(message="Invite accepted.", status="In Progress") + except InviteAlreadySentException: + raise HTTPException(status_code=409, detail="Invite already sent.") - -@router.post("/confirm-invite", description="Confirms user invite by signing them in using the link sent to their email") -def confirm_invite( - body: ConfirmInviteRequest, - settings: SettingsDep, - cognito_client: CognitoIdpDep, - calc_secret_hash: SecretHashFuncDep -): +@router.post( + "/confirm-invite", + description= + "Confirms user invite by signing them in using the link sent to their email" +) +def confirm_invite(body: ConfirmInviteRequest, settings: SettingsDep, + cognito_client: CognitoIdpDep, + calc_secret_hash: SecretHashFuncDep): + """Confirm user invite by signing them in using the link sent to their email.""" secret_hash = calc_secret_hash(body.email) - + try: auth_response = cognito_client.initiate_auth( ClientId=settings.COGNITO_CLIENT_ID, @@ -399,43 +361,54 @@ def confirm_invite( 'USERNAME': body.email, 'PASSWORD': body.password, 'SECRET_HASH': secret_hash - } - ) - + }) + if auth_response.get('ChallengeName') == 'NEW_PASSWORD_REQUIRED': userId = auth_response['ChallengeParameters']['USER_ID_FOR_SRP'] sessionId = auth_response['Session'] - return RedirectResponse(f"{settings.ROOT_URL}/create-password?userId={userId}&sessionId={sessionId}") + return RedirectResponse( + f"{settings.ROOT_URL}/create-password?userId={userId}&sessionId={sessionId}" + ) else: - return RedirectResponse(f"{settings.ROOT_URL}/create-password?error=There was an unexpected error. Please try again.") + return RedirectResponse( + f"{settings.ROOT_URL}/create-password?error=There was an unexpected error. Please try again." + ) except ClientError as e: error_code = e.response['Error']['Code'] error_messages = { - 'NotAuthorizedException': "Incorrect username or password. Your invitation link may be invalid.", - 'UserNotFoundException': "User not found. Confirmation not sent.", - 'TooManyRequestsException': "Too many attempts to use invite in a short amount of time." + 'NotAuthorizedException': + "Incorrect username or password. Your invitation link may be invalid.", + 'UserNotFoundException': + "User not found. Confirmation not sent.", + 'TooManyRequestsException': + "Too many attempts to use invite in a short amount of time." } msg = error_messages.get(error_code, e.response['Error']['Message']) - raise HTTPException(status_code=400, detail={"code": error_code, "message": msg}) + raise HTTPException(status_code=400, + detail={ + "code": error_code, + "message": msg + }) except ParamValidationError as e: msg = f"The parameters you provided are incorrect: {e}" - raise HTTPException(status_code=400, detail={"code": "ParamValidationError", "message": msg}) + raise HTTPException(status_code=400, + detail={ + "code": "ParamValidationError", + "message": msg + }) +@router.post( + "/new-password", + description= + "Removes auto generated password and replaces with user assigned password. Used for account setup.", + response_model=UserSignInResponse) +def new_password(body: NewPasswordRequest, response: Response, + settings: SettingsDep, db: DbSessionDep, + cognito_client: CognitoIdpDep, + calc_secret_hash: SecretHashFuncDep): -@router.post("/new-password", - description="Removes auto generated password and replaces with user assigned password. Used for account setup.", - response_model=UserSignInResponse) -def new_password( - body: NewPasswordRequest, - response: Response, - settings: SettingsDep, - db: DbSessionDep, - cognito_client: CognitoIdpDep, - calc_secret_hash: SecretHashFuncDep -): - secret_hash = calc_secret_hash(body.userId) try: @@ -450,10 +423,11 @@ def new_password( }, ) except ClientError as e: - raise HTTPException(status_code=500, detail={ - "code": e.response['Error']['Code'], - "message": e.response['Error']['Message'] - }) + raise HTTPException(status_code=500, + detail={ + "code": e.response['Error']['Code'], + "message": e.response['Error']['Message'] + }) access_token = auth_response['AuthenticationResult']['AccessToken'] refresh_token = auth_response['AuthenticationResult']['RefreshToken'] @@ -468,15 +442,10 @@ def new_password( if user is None: raise HTTPException(status_code=404, detail="User not found") except Exception as e: - raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") + raise HTTPException(status_code=500, + detail=f"Database error: {str(e)}") response.set_cookie("refresh_token", refresh_token, httponly=True) response.set_cookie("id_token", id_token, httponly=True) - return { - "user": user, - "token": access_token - } - - - + return {"user": user, "token": access_token} diff --git a/backend/app/modules/access/crud.py b/backend/app/modules/access/crud.py index 6cdb72ca8..4b11090be 100644 --- a/backend/app/modules/access/crud.py +++ b/backend/app/modules/access/crud.py @@ -8,11 +8,11 @@ def get_role(db: Session, role: int): return db.query(models.Role).filter(models.Role.type == role.value).first() -def get_user(db: Session, email: str): +def get_user(db: Session, email: models.EmailAddress): return db.query(models.User).filter(models.User.email == email).first() -def get_user_by_id(db: Session, user_id: int): - return db.query(models.User).filter(models.User.id == user_id).first() +def get_user_by_id(db: Session, user_id: models.UserId): + return db.query(models.User).filter(models.User.user_id == user_id).first() def create_user(db: Session, user: schemas.UserCreate): role = get_role(db, user.role) diff --git a/backend/app/modules/access/hosts_controller.py b/backend/app/modules/access/hosts_controller.py index 6c5b1aa70..aa4671e40 100644 --- a/backend/app/modules/access/hosts_controller.py +++ b/backend/app/modules/access/hosts_controller.py @@ -1,4 +1,4 @@ -from .user_roles import UserRole +from .models import UserRoleEnum from . import schemas from .user_repo import UserRepository @@ -13,5 +13,5 @@ def get_hosts(db_session: DbSessionDep) -> list[schemas.User]: with db_session.begin(): user_repo = UserRepository(db_session) - all_users = user_repo.get_users_with_role(UserRole.HOST) + all_users = user_repo.get_users_with_role(UserRoleEnum.HOST) return all_users diff --git a/backend/app/modules/access/invite/application_service.py b/backend/app/modules/access/invite/application_service.py new file mode 100644 index 000000000..aa9101e51 --- /dev/null +++ b/backend/app/modules/access/invite/application_service.py @@ -0,0 +1,49 @@ +from collections.abc import Callable +from datetime import datetime + +from app.core.event_store import EventStore +from app.core.interfaces import DomainCommand, Identity +from ..models import User +from .contracts import InviteId, SendInviteCommand, ProcessSentInviteCommand +from .invite_aggregate import Invite + + +class InviteService: + + def __init__(self, event_store: EventStore, + expire_policy: Callable[[datetime], datetime], + user_service: Callable[[Identity], User]): + if event_store is None: + raise ValueError("Event Store needed") + if expire_policy is None: + raise ValueError("Expire policy needed") + if user_service is None: + raise ValueError("User service needed") + + self._event_store = event_store + self._expire_policy = expire_policy + self._user_service = user_service + + def execute(self, command: DomainCommand): + getattr(self, 'when_' + command.__class__.__name__)(command) + + def _update(self, id: InviteId, update: Callable[[Invite], None]): + event_stream = self._event_store.fetch(id) + invite = Invite(event_stream.events) + update(invite) + self._event_store.append(id, invite.changes, event_stream.version) + + def when_SendInviteCommand(self, cmd: SendInviteCommand): + func: Callable[[str, str], int] = lambda invite: invite.send_invite( + cmd.email, cmd.first_name, cmd.middle_name, cmd.last_name, cmd. + invitee_role, cmd.inviter_id, cmd.inviter_role, cmd.requested_at, + self._expire_policy, self._user_service) + + self._update(InviteId(id=cmd.email), func) + + def when_ProcessSentInviteCommand(self, cmd: ProcessSentInviteCommand): + func: Callable[[str, str], + int] = lambda invite: invite.process_sent_invite( + cmd.user_id, cmd.email, cmd.sent_at) + + self._update(InviteId(id=cmd.email), func) diff --git a/backend/app/modules/access/invite/contracts.py b/backend/app/modules/access/invite/contracts.py index b04eb710f..bdaf782a4 100644 --- a/backend/app/modules/access/invite/contracts.py +++ b/backend/app/modules/access/invite/contracts.py @@ -1,80 +1,126 @@ """The identities, classes, value objects that make up the Invite contracts.""" from app.core.interfaces import Identity, DomainCommand, DomainEvent +from ..models import EmailAddress, UserId from ..schemas import UserRoleEnum from dataclasses import dataclass from datetime import datetime -from typing import Any @dataclass(frozen=True) class InviteId(Identity): """The identity of an Invite.""" - id: str + id: EmailAddress def __str__(self): """Represent Invite ID as a string.""" return f"invite-{self.id}" +############################################################################### +# Domain Commands +############################################################################### + + +@dataclass class SendInviteCommand(DomainCommand): """Command with data needed to send an Invite.""" - full_name: str - email: str + first_name: str + middle_name: str | None + last_name: str + email: EmailAddress invitee_role: UserRoleEnum - inviter_id: str + inviter_id: Identity inviter_role: UserRoleEnum + requested_at: datetime + + +@dataclass +class ProcessSentInviteCommand(DomainCommand): + """Command to process a sent invite.""" + + user_id: UserId + email: EmailAddress sent_at: datetime +@dataclass +class FailedSentInviteCommand(DomainCommand): + """Command to indicate failed to send an invite.""" + + email: EmailAddress + reason: str + + +############################################################################### +# Domain Events +############################################################################### + + @dataclass class SendInviteRequestedDomainEvent(DomainEvent): """An Invite domain event.""" - email: str - full_name: str + email: EmailAddress + first_name: str + middle_name: str | None + last_name: str invitee_role: UserRoleEnum inviter_id: str + inviter_first_name: str + inviter_middle_name: str | None + inviter_last_name: str inviter_role: UserRoleEnum - sent_at: datetime + requested_at: datetime expire_at: datetime - @classmethod - def from_dict(cls, data: dict[str, Any]): - """Deserialize from dict to the correct event class.""" - sent_at = datetime.fromisoformat(data['sent_at']) - expire_at = datetime.fromisoformat(data['expire_at']) - return cls(full_name=data['full_name'], - email=data['email'], - invitee_role=data['invitee_role'], - inviter_id=data['inviter_id'], - inviter_role=data['inviter_role'], - sent_at=sent_at, - expire_at=expire_at) - @dataclass class InviteSentDomainEvent(DomainEvent): """An Invite domain event.""" - email: str - full_name: str - expire_at: datetime + user_id: UserId + email: EmailAddress + first_name: str + middle_name: str | None + last_name: str + role: UserRoleEnum + inviter_id: UserId + sent_at: datetime + + +@dataclass +class UserCreatedDomainEvent(DomainEvent): + """An Invite domain event.""" + + user_id: UserId + email: EmailAddress + first_name: str + middle_name: str | None + last_name: str + role: UserRoleEnum @dataclass class InviteAcceptedDomainEvent(DomainEvent): """An Invite was accepted domain event.""" - email: str + email: EmailAddress accepted_at: datetime - @classmethod - def from_dict(cls, data: dict[str, Any]): - """Deserialize from dict to the correct event class.""" - return cls(email=data['email'], accepted_at=data['accepted_at']) + +@dataclass +class InviteSentFailedDomainEvent(DomainEvent): + """An Invite failed to send.""" + + email: EmailAddress + + +############################################################################### +# Exceptions +############################################################################### class InviteAlreadySentException(Exception): diff --git a/backend/app/modules/access/invite/invite_aggregate.py b/backend/app/modules/access/invite/invite_aggregate.py index bcf1f62a2..9aac62ab1 100644 --- a/backend/app/modules/access/invite/invite_aggregate.py +++ b/backend/app/modules/access/invite/invite_aggregate.py @@ -1,30 +1,40 @@ """The Invite aggregate handles invites into the system.""" +from collections.abc import Callable +from datetime import datetime + from app.core.interfaces import DomainEvent +from ..models import EmailAddress, User, UserId from ..schemas import UserRoleEnum from .contracts import ( InviteId, SendInviteRequestedDomainEvent, InviteSentDomainEvent, InviteAcceptedDomainEvent, + InviteSentFailedDomainEvent, InviteAlreadySentException, NotInvitedException, + UserCreatedDomainEvent, ) -from collections.abc import Callable -from datetime import datetime - class InviteState: """Holds the state of the Invite aggregate.""" - email: InviteId = None - full_name: str = None - invitee_role: UserRoleEnum = None - inviter_id: str = None - inviter_role: UserRoleEnum = None - sent_at: datetime = None - expire_at: datetime = None - accepted_at: datetime = None + email: InviteId + first_name: str + middle_name: str | None + last_name: str + invitee_role: UserRoleEnum + inviter_id: str + inviter_first_name: str + inviter_middle_name: str | None + inviter_last_name: str + inviter_role: UserRoleEnum + requested_at: datetime + expire_at: datetime + sent_at: datetime + accepted_at: datetime + pending_send_invite: bool = False invited: bool = False @@ -41,22 +51,32 @@ def when_SendInviteRequestedDomainEvent( self, event: SendInviteRequestedDomainEvent): """Update the state of an Invite.""" self.email = event.email - self.full_name = event.full_name + self.first_name = event.first_name + self.middle_name = event.middle_name + self.last_name = event.last_name self.invitee_role = event.invitee_role self.inviter_id = event.inviter_id self.inviter_role = event.inviter_role - self.sent_at = event.sent_at + self.requested_at = event.requested_at + self.expire_at = event.expire_at self.pending_send_invite = True def when_InviteSentDomainEvent(self, event: InviteSentDomainEvent): """Update the state of an Invite.""" + self.sent_at = event.sent_at + self.invited = True def when_InviteAcceptedDomainEvent(self, event: InviteAcceptedDomainEvent): """Update state of an Invite.""" self.accepted_at = event.accepted_at + def when_InviteSentFailedDomainEvent(self, + event: InviteSentFailedDomainEvent): + """Update state of an Invite.""" + pass + class Invite: """Allows invites to be sent and managed by existing users.""" @@ -75,29 +95,55 @@ def changes(self): """See a view into the events that cause state changes.""" return self._changes - def send_invite(self, full_name: str, email: str, + def send_invite(self, email: EmailAddress, first_name: str, + middle_name: str | None, last_name: str, invitee_role: UserRoleEnum, inviter_id: str, - inviter_role: UserRoleEnum, sent_at: datetime, - expire_policy: Callable[[datetime], datetime]): + inviter_role: UserRoleEnum, requested_at: datetime, + expire_policy: Callable[[datetime], datetime], + user_service: Callable[[UserId], User]): """Send an invite to the given recipient.""" if self._state.pending_send_invite or self._state.invited: raise InviteAlreadySentException(email) - e = SendInviteRequestedDomainEvent(email=email, - full_name=full_name, - invitee_role=invitee_role, - inviter_id=inviter_id, - inviter_role=inviter_role, - sent_at=sent_at, - expire_at=expire_policy(sent_at)) + inviter = user_service(inviter_id) + + e = SendInviteRequestedDomainEvent( + email=email, + first_name=first_name, + middle_name=middle_name, + last_name=last_name, + invitee_role=invitee_role, + inviter_id=inviter_id, + inviter_first_name=inviter.first_name, + inviter_middle_name=inviter.middle_name, + inviter_last_name=inviter.last_name, + inviter_role=inviter_role, + requested_at=requested_at, + expire_at=expire_policy(requested_at)) + self._apply(e) - def process_sent_invite(self, email: str): + def process_sent_invite(self, user_id: UserId, email: EmailAddress, + sent_at: datetime): """Process a sent invite.""" - e = InviteSentDomainEvent(email, self._state.full_name, - self._state.expire_at) - - self._apply(e) + e1 = InviteSentDomainEvent(user_id=user_id, + email=email, + first_name=self._state.first_name, + middle_name=self._state.middle_name, + last_name=self._state.last_name, + role=self._state.invitee_role, + inviter_id=self._state.inviter_id, + sent_at=sent_at) + + e2 = UserCreatedDomainEvent(user_id=user_id, + email=email, + first_name=self._state.first_name, + middle_name=self._state.middle_name, + last_name=self._state.last_name, + role=self._state.invitee_role) + + self._apply(e1) + self._apply(e2) def accept_invite(self, email: str, accepted_at: datetime): """Accept an invite.""" @@ -107,3 +153,9 @@ def accept_invite(self, email: str, accepted_at: datetime): e = InviteAcceptedDomainEvent(email=email, accepted_at=accepted_at) self._apply(e) + + def failed_invite_send(self, email: str): + """Send invite failed.""" + e = InviteSentFailedDomainEvent(email) + + self._apply(e) diff --git a/backend/app/modules/access/invite/processor.py b/backend/app/modules/access/invite/processor.py new file mode 100644 index 000000000..b6e8e7ee2 --- /dev/null +++ b/backend/app/modules/access/invite/processor.py @@ -0,0 +1,88 @@ +from datetime import datetime, timezone +import logging +import random + +from botocore.exceptions import ClientError + +from app.core.config import Settings +from app.core.interfaces import DomainEvent +import app.core.message_bus as message_bus +from app.modules.access.models import UserId +from app.modules.access.invite.contracts import ( + SendInviteRequestedDomainEvent, ProcessSentInviteCommand, + FailedSentInviteCommand) + +NUMBERS = '0123456789' +LOWERCASE_CHARS = 'abcdefghijklmnopqrstuvwxyz' +UPPERCASE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' +SYMBOLS = '.-_~' + +log = logging.Logger(__name__) + + +class CognitoInviteUserProcessor: + + def __init__(self, cognito_client, settings: Settings): + if cognito_client is None: + raise ValueError("Expected a Cognito client but got none.") + if settings is None: + raise ValueError("Expected Settings but got none.") + self._cognito_client = cognito_client + self._settings = settings + + def mutate(self, domain_event: DomainEvent): + """Process domain event.""" + getattr(self, 'when_' + domain_event.__class__.__name__)(domain_event) + + def when_SendInviteRequestedDomainEvent( + self, event: SendInviteRequestedDomainEvent): + """Send the invite via Cognito create user.""" + + invite_to_send = event + cognito_client = self._cognito_client + settings = self._settings + + temporary_password = ''.join(random.choices(NUMBERS, k=3)) + ''.join( + random.choices(LOWERCASE_CHARS, k=3)) + ''.join( + random.choices(SYMBOLS, k=1)) + ''.join( + random.choices(UPPERCASE_CHARS, k=3)) + + try: + response = cognito_client.admin_create_user( + UserPoolId=settings.COGNITO_USER_POOL_ID, + Username=invite_to_send.email, + TemporaryPassword=temporary_password, + ClientMetadata={'url': settings.ROOT_URL}, + DesiredDeliveryMediums=["EMAIL"]) + + sub = [ + s['Value'] for s in response['User']['Attributes'] + if s['Name'] == 'sub' + ] + guest_id = sub[0] + + cmd = ProcessSentInviteCommand( + user_id=UserId(guest_id), + email=invite_to_send.email, + sent_at=datetime.now(timezone.utc), + ) + message_bus.handle(cmd) + except ClientError as error: + if error.response['Error']['Code'] == 'UsernameExistsException': + log.error( + f'User with email {invite_to_send.email} already exists.') + cmd = FailedSentInviteCommand( + invite_to_send.email, + "User with this email exists already.") + else: + log.error(f'Cognito AdminCreateUser: ' + + error.response['Code'] + " " + + error.response['Message']) + cmd = FailedSentInviteCommand(invite_to_send.email, + "Programming error.") + + message_bus.handle(cmd) + except IndexError: + cmd = FailedSentInviteCommand(invite_to_send.email, + "Programming error.") + message_bus.handle(cmd) diff --git a/backend/app/modules/access/models.py b/backend/app/modules/access/models.py index d83fcff1e..99124bad6 100644 --- a/backend/app/modules/access/models.py +++ b/backend/app/modules/access/models.py @@ -1,45 +1,16 @@ """Model.""" -from email_validator import validate_email, EmailNotValidError - -from sqlalchemy import Column, ForeignKey, Integer, String -from sqlalchemy.orm import relationship -from sqlalchemy.orm import validates as validates_sqlachemy -from sqlalchemy import create_engine, text -from sqlalchemy.engine import Engine -from sqlalchemy.exc import SQLAlchemyError - -from app.core.db import Base -from app.core.interfaces import ValueObject - from dataclasses import dataclass +from enum import Enum +import uuid +from email_validator import validate_email, EmailNotValidError +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.types import TypeDecorator, Uuid, String -class User(Base): - __tablename__ = "user" - id = Column(Integer, primary_key=True, index=True) - email = Column(String, nullable=False, unique=True) - firstName = Column(String(255), nullable=False) - middleName = Column(String(255), nullable=True) - lastName = Column(String(255), nullable=True) - roleId = Column(Integer, ForeignKey("role.id"), nullable=False) - - role = relationship("Role", back_populates="users") - - @validates_sqlachemy("firstName") - def validate_first_name(self, key, value): - if not value or not value.strip(): - raise ValueError( - f"{key} must contain at least one non-space character") - return value.strip() - - -class Role(Base): - __tablename__ = "role" - id = Column(Integer, primary_key=True, index=True) - type = Column(String, nullable=False, unique=True) - - users = relationship("User", back_populates="role") +from app.core.db import Base +from app.core.interfaces import Identity, ValueObject class InvalidEmailError(Exception): @@ -47,7 +18,7 @@ class InvalidEmailError(Exception): @dataclass(frozen=True) -class EmailAddress(ValueObject): +class EmailAddress(ValueObject, str): """Represent a valid email address.""" email: str @@ -84,3 +55,96 @@ def __eq__(self, o): def __str__(self): return self.email + + +class EmailAddressType(TypeDecorator): + impl = String # Use String or another appropriate base SQL type + cache_ok = True + + def process_bind_param(self, value: EmailAddress, dialect): + # Convert EmailAddress instance to string before storing in DB + return str(value.email) if value else None + + def process_result_value(self, value, dialect): + # Convert string from DB back to EmailAddress instance + return EmailAddress(value) if value else None + + +class UserRoleEnum(str, Enum): + ADMIN = "admin" + GUEST = "guest" + HOST = "host" + COORDINATOR = "coordinator" + + +class Role(Base): + __tablename__ = "roles" + role: Mapped[str] = mapped_column(primary_key=True) + + users: Mapped[list["User"]] = relationship(back_populates="role_relation") + +class UserId(Identity, str): + id: uuid.UUID + + def __init__(self, id: uuid.UUID = None): + if id is None: + self.id = uuid.uuid4() + self.id = uuid.UUID(f'{id}') + + def __str__(self): + return str(self.id) + + +class UserIdType(TypeDecorator): + impl = Uuid # Use String or another appropriate base SQL type + cache_ok = True + + def process_bind_param(self, value: UserId, dialect): + # Convert UserId instance to string before storing in DB + return str(value.id) if value else None + + def process_result_value(self, value, dialect): + # Convert string from DB back to UserId instance + return UserId(value) if value else None + + +class User(Base): + __tablename__ = "users" + user_id: Mapped[UserId] = mapped_column(UserIdType, primary_key=True) + email: Mapped[EmailAddress] = mapped_column(EmailAddressType, + unique=True, + nullable=False, + index=True) + first_name: Mapped[str] + middle_name: Mapped[str | None] + last_name: Mapped[str] + role: Mapped[str] = mapped_column(ForeignKey("roles.role")) + role_relation: Mapped["Role"] = relationship(back_populates="users") + disabled: bool = False + + @classmethod + def coordinator(cls, email, first_name, middle_name, last_name) -> "User": + return cls(id=UserId(), + email=email, + first_name=first_name, + middle_name=middle_name, + last_name=last_name, + role=UserRoleEnum.COORDINATOR.value) + + @classmethod + def guest(cls, email, first_name, middle_name, last_name) -> "User": + return cls(id=UserId(), + email=email, + first_name=first_name, + middle_name=middle_name, + last_name=last_name, + role=UserRoleEnum.GUEST.value) + + @classmethod + def host(cls, email, first_name, middle_name, last_name) -> "User": + return cls(id=UserId(), + email=email, + first_name=first_name, + middle_name=middle_name, + last_name=last_name, + role=UserRoleEnum.HOST.value) diff --git a/backend/app/modules/access/schemas.py b/backend/app/modules/access/schemas.py index 609b1b859..cbb0072b7 100644 --- a/backend/app/modules/access/schemas.py +++ b/backend/app/modules/access/schemas.py @@ -1,27 +1,13 @@ -from pydantic import BaseModel, ConfigDict, EmailStr +from pydantic import BaseModel, ConfigDict, EmailStr, Field -from enum import Enum - - -class UserRoleEnum(str, Enum): - ADMIN = "admin" - GUEST = "guest" - HOST = "host" - COORDINATOR = "coordinator" - - -class RoleBase(BaseModel): - id: int - type: UserRoleEnum - - model_config = ConfigDict(from_attributes=True) +from .models import UserId, UserRoleEnum class UserBase(BaseModel): email: EmailStr - firstName: str - middleName: str | None = None - lastName: str | None = None + first_name: str + middle_name: str | None = None + last_name: str | None = None class UserCreate(UserBase): @@ -30,8 +16,8 @@ class UserCreate(UserBase): class User(UserBase): - id: int - role: RoleBase + id: str = Field(alias="user_id") + role: UserRoleEnum model_config = ConfigDict(from_attributes=True) @@ -43,7 +29,9 @@ class UserSignInRequest(BaseModel): class UserSignInResponse(BaseModel): user: User - token: str + access_token: str + id_token: str + token_type: str class RefreshTokenResponse(BaseModel): @@ -69,49 +57,25 @@ class InviteRequest(BaseModel): firstName: str middleName: str lastName: str + role: UserRoleEnum = UserRoleEnum.GUEST + + +class InviteResponse(BaseModel): + message: str + status: str + class Cookies(BaseModel): - refresh_token: str - id_token: str + refresh_token: str + id_token: str + class ConfirmInviteRequest(BaseModel): email: str password: str + class NewPasswordRequest(BaseModel): userId: str password: str sessionId: str - - -# class SmartNested(Nested): -# ''' -# Schema attribute used to serialize nested attributes to -# primary keys, unless they are already loaded. This -# enables serialization of complex nested relationships. -# Modified from -# https://marshmallow-sqlalchemy.readthedocs.io/en/latest/recipes.html#smart-nested-field -# ''' - -# def serialize(self, attr, obj, accessor=None): -# if hasattr(obj, attr): -# value = getattr(obj, attr, None) -# if value is None: -# return None -# elif hasattr(value, 'id'): -# return {"id": value.id} -# else: -# return super(SmartNested, self).serialize(attr, obj, accessor) -# else: -# raise AttributeError( -# f"{obj.__class__.__name__} object has no attribute '{attr}'") - -# class RoleSchema(BaseModel): - -# model_config = ConfigDict(from_attributes=True) - -# class UserSchema(BaseModel): -# model_config = ConfigDict(from_attributes=True) - -# user_schema = UserSchema() -# users_schema = UserSchema(many=True) diff --git a/backend/app/modules/access/user_repo.py b/backend/app/modules/access/user_repo.py index 97d41bf62..01e141de8 100644 --- a/backend/app/modules/access/user_repo.py +++ b/backend/app/modules/access/user_repo.py @@ -1,54 +1,51 @@ -from app.modules.access.models import User, Role -from app.modules.access.user_roles import UserRole +from app.modules.access.models import UserId, User, Role, UserRoleEnum class UserRepository: - def __init__(self, session): - self.session = session - - def _get_role(self, role: UserRole) -> Role: - db_role = self.session.query(Role).filter_by(type=role.value).first() - if not db_role: - raise ValueError(f"{role.value} is not a valid user role type") - return db_role + def __init__(self, session_factory): + self.session_factory = session_factory def add_user(self, email: str, - role: UserRole, + role: UserRoleEnum, firstName: str, middleName: str = None, lastName: str = None) -> User: - new_role = self._get_role(role) new_user = User(email=email, firstName=firstName, middleName=middleName, lastName=lastName, - roleId=new_role.id) - self.session.add(new_user) - self.session.commit() + role=role.value) + with self.session_factory.begin() as session: + session.add(new_user) return new_user - def delete_user(self, user_id: int) -> bool: - user = self.session.query(User).filter_by(id=user_id).first() - if user: - self.session.delete(user) - self.session.commit() - return True - return False + def delete_user(self, user_id: UserId) -> bool: + with self.session_factory.begin() as session: + user = session.query(User).filter_by(user_id=user_id).first() + if user: + session.delete(user) + return True + return False - def get_user_by_id(self, id: int) -> User: - return self.session.query(User).filter_by(id=id).first() + def get_user_by_id(self, id: UserId) -> User: + with self.session_factory() as session: + return session.query(User).filter_by(user_id=id).first() def get_user(self, email: str) -> User: - return self.session.query(User).filter_by(email=email).first() + with self.session_factory() as session: + return session.query(User).filter_by(email=email).first() def get_all_users(self) -> list[User]: - return self.session.query(User).all() + with self.session_factory() as session: + return session.query(User).all() - def get_user_id(self, email: str) -> int: - return self.session.query(User).filter_by(email=email).first().id + def get_user_id(self, email: str) -> UserId: + with self.session_factory() as session: + return session.query(User).filter_by(email=email).first().user_id - def get_users_with_role(self, role: UserRole) -> list[User]: - return self.session.query(User).filter_by(role=self._get_role(role)) + def get_users_with_role(self, role: UserRoleEnum) -> list[User]: + with self.session_factory() as session: + return session.query(User).filter_by(role=role.value) diff --git a/backend/app/modules/access/user_roles.py b/backend/app/modules/access/user_roles.py deleted file mode 100644 index 198fbaca5..000000000 --- a/backend/app/modules/access/user_roles.py +++ /dev/null @@ -1,8 +0,0 @@ -from enum import Enum - - -class UserRole(Enum): - ADMIN = "admin" - GUEST = "guest" - HOST = "host" - COORDINATOR = "coordinator" diff --git a/backend/app/modules/deps.py b/backend/app/modules/deps.py index a96116cac..bf42cebc5 100644 --- a/backend/app/modules/deps.py +++ b/backend/app/modules/deps.py @@ -1,17 +1,27 @@ import boto3 +from dataclasses import dataclass, field +from datetime import timedelta import jwt +from jwt.exceptions import InvalidTokenError import time import hmac import base64 - from typing import Annotated, Any, Callable -from fastapi import Depends, Request, HTTPException -from fastapi.security import SecurityScopes +from fastapi import Depends, Request, HTTPException, Security, status +from fastapi.security import ( + OAuth2PasswordBearer, + SecurityScopes, +) from sqlalchemy.orm import Session import app.core.db as db import app.core.config as config +from app.core.event_store import EventStore +import app.core.sa_event_store as sa_event_store +from app.modules.access.crud import get_user_by_id +from app.modules.access.models import UserId, User, UserRoleEnum +from app.modules.access.invite.application_service import InviteService ################################################################################ # Loading forms JSON description from disk @@ -35,6 +45,8 @@ def get_form_2(): with open("form_data/form2.json", "r") as f: FORM_2 = json.load(f) return FORM_2 + + ################################################################################ SettingsDep = Annotated[config.Settings, Depends(config.get_settings)] @@ -59,14 +71,19 @@ def db_session(engine: DbEngineDep): DbSessionDep = Annotated[Session, Depends(db_session)] +def event_store(db_session: DbSessionDep): + return sa_event_store.SqlAlchemyEventStore(db_session) + + +EventStoreDep = Annotated[EventStore, Depends(event_store)] + + def get_cognito_client(settings: SettingsDep): - return boto3.client( - "cognito-idp", - region_name=settings.COGNITO_REGION, - aws_access_key_id=settings.COGNITO_ACCESS_ID, - aws_secret_access_key=settings.COGNITO_ACCESS_KEY, - endpoint_url=settings.COGNITO_ENDPOINT_URL - ) + return boto3.client("cognito-idp", + region_name=settings.COGNITO_REGION, + aws_access_key_id=settings.COGNITO_ACCESS_ID, + aws_secret_access_key=settings.COGNITO_ACCESS_KEY, + endpoint_url=settings.COGNITO_ENDPOINT_URL) CognitoIdpDep = Annotated[Any, Depends(get_cognito_client)] @@ -140,3 +157,106 @@ def hash(username: str) -> str: SecretHashFuncDep = Annotated[Callable, Depends(secret_hash_func)] + +oauth2_scheme = OAuth2PasswordBearer( + tokenUrl="api/auth/signin", + scopes={ + "me": "Read information about the current user.", + }, +) + + +@dataclass +class TokenData: + user_id: UserId | None = None + scopes: list[str] = field(default_factory=list) + + +def get_current_user(security_scopes: SecurityScopes, + token: Annotated[str, Depends(oauth2_scheme)], + db_session: DbSessionDep): + if security_scopes.scopes: + authenticate_value = f'Bearer scope="{security_scopes.scope_str}"' + else: + authenticate_value = "Bearer" + + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": authenticate_value}, + ) + + try: + payload = jwt.decode(token, + algorithms=["RS256"], + options={"verify_signature": False}) + except InvalidTokenError: + raise credentials_exception + + sub: str = payload.get("sub") + if sub is None: + raise credentials_exception + + token_scopes = payload.get("scopes", []) + token_data = TokenData(scopes=token_scopes, user_id=UserId(sub)) + + user = get_user_by_id(db_session, token_data.user_id) + if user is None: + raise credentials_exception + + for scope in security_scopes.scopes: + if scope not in token_data.scopes: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not enough permissions", + headers={"WWW-Authenticate": authenticate_value}, + ) + return user + + +def get_current_active_user( + current_user: Annotated[User, + Security(get_current_user, scopes=[])]): + if current_user.disabled: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + + +def get_current_active_coordinator( + current_user: Annotated[User, Depends(get_current_active_user)]): + if UserRoleEnum(current_user.role) is not UserRoleEnum.COORDINATOR: + raise HTTPException(status_code=400, detail="Not a coordinator") + return current_user + + +CoordinatorDep = Annotated[User, Depends(get_current_active_coordinator)] + + +def get_current_active_guest( + current_user: Annotated[User, Depends(get_current_active_user)]): + if UserRoleEnum(current_user.role) is not UserRoleEnum.GUEST: + raise HTTPException(status_code=400, detail="Not a guest") + return current_user + + +GuestDep = Annotated[User, Depends(get_current_active_guest)] + + +def get_current_active_host( + current_user: Annotated[User, Depends(get_current_active_user)]): + if UserRoleEnum(current_user.role) is not UserRoleEnum.HOST: + raise HTTPException(status_code=400, detail="Not a host") + return current_user + + +HostDep = Annotated[User, Depends(get_current_active_host)] + + +def get_current_active_admin( + current_user: Annotated[User, Depends(get_current_active_user)]): + if UserRoleEnum(current_user.role) is not UserRoleEnum.ADMIN: + raise HTTPException(status_code=400, detail="Not a admin") + return current_user + + +AdminDep = Annotated[User, Depends(get_current_active_admin)] diff --git a/backend/app/modules/intake_profile/forms/models.py b/backend/app/modules/intake_profile/forms/models.py index 620f7e047..cd4d803f7 100644 --- a/backend/app/modules/intake_profile/forms/models.py +++ b/backend/app/modules/intake_profile/forms/models.py @@ -7,7 +7,9 @@ from sqlalchemy.orm import relationship from sqlalchemy.schema import CheckConstraint from sqlalchemy.sql import func + from app.core.db import Base +from app.modules.access.models import UserId intpk = Annotated[int, mapped_column(primary_key=True)] @@ -73,7 +75,7 @@ class Field(Base): class Response(Base): __tablename__ = 'responses' answer_id: Mapped[intpk] - user_id: Mapped[int] = mapped_column(ForeignKey('user.id'), nullable=False) + user_id: Mapped[UserId] = mapped_column(ForeignKey('users.user_id'), nullable=False) field_id: Mapped[int] = mapped_column(ForeignKey('fields.field_id'), nullable=False) answer_text: Mapped[str] diff --git a/backend/app/modules/workflow/dashboards/coordinator/coordinator_dashboard.py b/backend/app/modules/workflow/dashboards/coordinator/coordinator_dashboard.py index 57082da81..d7ae7bc95 100644 --- a/backend/app/modules/workflow/dashboards/coordinator/coordinator_dashboard.py +++ b/backend/app/modules/workflow/dashboards/coordinator/coordinator_dashboard.py @@ -1,74 +1,35 @@ +from datetime import datetime + from fastapi import APIRouter, status -from fastapi.responses import JSONResponse +from pydantic import BaseModel, ConfigDict -from ...unmatched_guest_case import UnmatchedCaseRepository -from app.modules.access.user_repo import UserRepository -from app.modules.access.user_roles import UserRole -from app.modules.deps import DbSessionDep +from app.modules.deps import DbSessionDep, CoordinatorDep +import app.projections.dashboard_users_view as view router = APIRouter() -@router.get("/coordinator/dashboard/all", status_code=status.HTTP_200_OK) -def get_dashboard_data(db_session: DbSessionDep) -> JSONResponse: - """ +class DashboardUsers(BaseModel): + model_config = ConfigDict(from_attributes=True) - userName: - type: string - caseStatus: - type: string - coordinatorName: - type: string - userType: - type: string - lastUpdated: - type: string - notes: - type: string - """ - with db_session.begin(): - user_repo = UserRepository(db_session) - coordinator_users_by_id = { - x.id: x - for x in user_repo.get_users_with_role(UserRole.COORDINATOR) - } - case_repo = UnmatchedCaseRepository(db_session) + user_id: str + email: str + name: str + role: str + status: str + coordinator_name: str + updated: datetime + notes: str - all_users = [] - for guest in user_repo.get_users_with_role(UserRole.GUEST): - case_status = case_repo.get_case_for_guest(int(guest.id)) - coordinator = coordinator_users_by_id[case_status.coordinator_id] - all_users.append({ - 'id': guest.id, - 'userName': f'{guest.firstName} {guest.lastName}', - 'caseStatus': 'In Progress', - 'userType': 'GUEST', - 'coordinatorName': - f'{coordinator.firstName} {coordinator.lastName}', - 'lastUpdated': '2024-08-25', - 'Notes': 'N/A' - }) - for host in user_repo.get_users_with_role(UserRole.HOST): - all_users.append({ - 'id': host.id, - 'userName': f'{host.firstName} {host.lastName}', - 'caseStatus': 'In Progress', - 'userType': 'HOST', - 'coordinatorName': f'N/A', - 'lastUpdated': '2024-08-25', - 'Notes': 'N/A' - }) +class DashboardUsersView(BaseModel): + dashboardItems: list[DashboardUsers] - for coordinator in user_repo.get_users_with_role(UserRole.COORDINATOR): - all_users.append({ - 'id': coordinator.id, - 'userName': f'{coordinator.firstName} {coordinator.lastName}', - 'caseStatus': 'N/A', - 'userType': 'COORDINATOR', - 'coordinatorName': f'N/A', - 'lastUpdated': '2024-08-25', - 'Notes': 'N/A' - }) - return JSONResponse(content={'dashboardItems': all_users}) +@router.get("/coordinator/dashboard/all", status_code=status.HTTP_200_OK) +def get_dashboard_data(db_session: DbSessionDep, + coordinator: CoordinatorDep) -> DashboardUsersView: + all_users = view.view(db_session) + for u in all_users: + print(u.user_id, u.email, u.name, u.status, u.role) + return DashboardUsersView(dashboardItems=all_users) diff --git a/backend/app/modules/workflow/dashboards/coordinator/schemas.py b/backend/app/modules/workflow/dashboards/coordinator/schemas.py deleted file mode 100644 index 5d5d8e305..000000000 --- a/backend/app/modules/workflow/dashboards/coordinator/schemas.py +++ /dev/null @@ -1,9 +0,0 @@ -from pydantic import BaseModel, ConfigDict - - -class UnmatchedCaseSchema(BaseModel): - model_config = ConfigDict(from_attributes=True) - - -class UnmatchedCaseStatusSchema(BaseModel): - model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/modules/workflow/models.py b/backend/app/modules/workflow/models.py deleted file mode 100644 index ea2445744..000000000 --- a/backend/app/modules/workflow/models.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Annotated -from sqlalchemy import ForeignKey -from sqlalchemy.orm import Mapped -from sqlalchemy.orm import mapped_column -from sqlalchemy.orm import relationship - -from app.core.db import Base - -intpk = Annotated[int, mapped_column(primary_key=True)] - - -class UnmatchedGuestCase(Base): - __tablename__ = "unmatched_guest_case" - id: Mapped[intpk] - guest_id: Mapped[int] = mapped_column(ForeignKey('user.id'), - nullable=False) - coordinator_id: Mapped[int] = mapped_column(ForeignKey('user.id'), - nullable=False) - status_id: Mapped[int] = mapped_column( - ForeignKey('unmatched_guest_case_status.id'), nullable=False) - status: Mapped["UnmatchedGuestCaseStatus"] = relationship( - back_populates="cases") - - -class UnmatchedGuestCaseStatus(Base): - __tablename__ = "unmatched_guest_case_status" - id: Mapped[intpk] - status_text: Mapped[str] = mapped_column(nullable=False, unique=True) - cases: Mapped["UnmatchedGuestCase"] = relationship(back_populates="status") diff --git a/backend/app/modules/workflow/unmatched_guest_case.py b/backend/app/modules/workflow/unmatched_guest_case.py deleted file mode 100644 index b0c7d1eea..000000000 --- a/backend/app/modules/workflow/unmatched_guest_case.py +++ /dev/null @@ -1,38 +0,0 @@ -from .models import UnmatchedGuestCase, UnmatchedGuestCaseStatus - -from enum import Enum - -class UmatchedCaseStatus(Enum): - IN_PROGRESS = "In Progress" - COMPLETE = "Complete" - - -class UnmatchedCaseRepository: - - def __init__(self, session): - self.session = session - - def add_case(self, guest_id: int, - coordinator_id: int) -> UnmatchedGuestCase: - status_id = self.session.query(UnmatchedGuestCaseStatus).filter_by( - status_text=UmatchedCaseStatus.IN_PROGRESS).first().id - new_guest_case = UnmatchedGuestCase(guest_id=guest_id, - coordinator_id=coordinator_id, - status_id=status_id) - self.session.add(new_guest_case) - self.session.commit() - - return new_guest_case - - def delete_case_for_guest(self, guest_id: int) -> bool: - guest_case = self.session.query(UnmatchedGuestCaseStatus).filter_by( - guest_id=guest_id).first() - if guest_case: - self.session.delete(guest_case) - self.session.commit() - return True - return False - - def get_case_for_guest(self, guest_id: int) -> UnmatchedGuestCase: - return self.session.query(UnmatchedGuestCase).filter_by( - guest_id=guest_id).first() diff --git a/backend/app/projections/dashboard_users_view.py b/backend/app/projections/dashboard_users_view.py new file mode 100644 index 000000000..68d4249ed --- /dev/null +++ b/backend/app/projections/dashboard_users_view.py @@ -0,0 +1,83 @@ +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session, Mapped, mapped_column + +from app.core.db import Base +from app.core.interfaces import DomainEvent +from app.modules.access.invite.contracts import ( + SendInviteRequestedDomainEvent, + InviteSentDomainEvent, + InviteSentFailedDomainEvent, +) + + +class DashboardUsersReadModel(Base): + """ + id: + type: string + userName: + type: string + caseStatus: + type: string + coordinatorName: + type: string + userType: + type: string + lastUpdated: + type: string + notes: + type: string + """ + __tablename__ = "dashboard_users_read_model" + user_id: Mapped[str] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(primary_key=True) + name: Mapped[str] + role: Mapped[str] + status: Mapped[str] + coordinator_name: Mapped[str] + updated: Mapped[datetime] + notes: Mapped[str] + + +# TODO: Add skip, limit, order +def view(session: Session) -> list[DashboardUsersReadModel]: + stmt = select(DashboardUsersReadModel) + return session.scalars(stmt).all() + + +class DashboardUsersProjection: + + def __init__(self, session_factory: Session): + if session_factory is None: + raise ValueError( + "Expected a SQLAlchemy Session Factory (result of sessionmaker) but got none." + ) + self._session_factory = session_factory + + def mutate(self, domain_event: DomainEvent): + """Update the projection based on the domain event.""" + getattr(self, 'when_' + domain_event.__class__.__name__)(domain_event) + + def when_SendInviteRequestedDomainEvent( + self, event: SendInviteRequestedDomainEvent): + with self._session_factory.begin() as session: + coordinator_name = f"{event.inviter_first_name} {event.inviter_last_name}" + read_model = DashboardUsersReadModel( + user_id="-1", + email=event.email, + name=event.last_name + ", " + event.first_name, + role=event.invitee_role, + status="Invite Pending", + coordinator_name=coordinator_name, + updated=event.requested_at, + notes="", + ) + session.add(read_model) + + def when_InviteSentDomainEvent(self, event: InviteSentDomainEvent): + stmt = select(DashboardUsersReadModel).filter_by(email=event.email) + with self._session_factory.begin() as session: + read_model = session.scalars(stmt).one_or_none() + if read_model: + read_model.status = "Invite Sent" diff --git a/backend/app/seed.py b/backend/app/seed.py index 238a55dec..c0d4d031c 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -3,10 +3,10 @@ from app.modules.access.models import Role INITIAL_ROLES = [ - {"type": "admin"}, - {"type": "guest"}, - {"type": "host"}, - {"type": "coordinator"}, + {"role": "admin"}, + {"role": "guest"}, + {"role": "host"}, + {"role": "coordinator"}, ] diff --git a/backend/startup_scripts/create_groups_users.py b/backend/startup_scripts/create_groups_users.py index 06c9b8033..46eae9424 100644 --- a/backend/startup_scripts/create_groups_users.py +++ b/backend/startup_scripts/create_groups_users.py @@ -7,13 +7,22 @@ def create_user(cognito_client, user_pool_id, email, group): """Create users in moto server cognitoidp preventing duplicates.""" try: - cognito_client.admin_get_user(UserPoolId=user_pool_id, Username=email) + response = cognito_client.admin_get_user(UserPoolId=user_pool_id, Username=email) + sub = [s['Value'] for s in response['UserAttributes'] if s['Name'] == 'sub'] + if len(sub) == 0: + raise Exception('No sub found.') + return sub[0] except Exception: # The exception means the user doesn't exist so it can now be created. - cognito_client.admin_create_user(UserPoolId=user_pool_id, - Username=email, - TemporaryPassword="Test123!", - MessageAction='SUPPRESS') + response = cognito_client.admin_create_user( + UserPoolId=user_pool_id, + Username=email, + TemporaryPassword="Test123!", + MessageAction='SUPPRESS') + + sub = [s['Value'] for s in response['User']['Attributes'] if s['Name'] == 'sub'] + if len(sub) == 0: + raise Exception('No sub found.') cognito_client.admin_confirm_sign_up(UserPoolId=user_pool_id, Username=email) @@ -24,6 +33,8 @@ def create_user(cognito_client, user_pool_id, email, group): GroupName=group, ) + return sub[0] + def create_group(groups, group, user_pool_id): """Create a group in moto server preventing duplicates.""" @@ -52,27 +63,29 @@ def create_group(groups, group, user_pool_id): create_group(groups, 'Coordinators', user_pool_id) rows = [] - create_user(cognito_client, user_pool_id, 'admin@example.com', 'Admins') - print('admin@example.com/Test123! created.') - rows.append(('admin@example.com', 'admin', 'admin', 1)) + user_id = create_user(cognito_client, user_pool_id, 'admin@example.com', 'Admins') + print(f'{user_id}/admin@example.com/Test123! created.') + rows.append( + (user_id, 'admin@example.com', 'admin', 'admin', 'admin')) - for role, role_id, group in [ - ('guest', 2, 'Guests'), - ('coordinator', 3, 'Coordinators'), - ('host', 4, 'Hosts'), + for role, group in [ + ('guest', 'Guests'), + ('coordinator', 'Coordinators'), + ('host', 'Hosts'), ]: for x in 'abcdefghijklmnopqrstuvwxyz': email = role + x + '@example.com' + user_id = create_user(cognito_client, user_pool_id, email, group) rows.append(( + user_id, email, role, x, - role_id, + role, )) - create_user(cognito_client, user_pool_id, email, group) - print(email + '/Test123! created.') + print(f'{user_id}/{email}/Test123! created.') - sql = 'INSERT INTO public.user (email, "firstName", "lastName", "roleId") VALUES (%s, %s, %s, %s) ON CONFLICT(email) DO NOTHING' + sql = 'INSERT INTO public.users (user_id, email, first_name, last_name, role) VALUES (%s, %s, %s, %s, %s) ON CONFLICT(email) DO UPDATE SET user_id = EXCLUDED.user_id' url = urlparse(os.environ['DATABASE_URL']) with psycopg2.connect(database=url.path[1:], user=url.username, diff --git a/backend/tests/unit/access/test_invite.py b/backend/tests/unit/access/test_invite.py index 7b58f0839..49f61e9cf 100644 --- a/backend/tests/unit/access/test_invite.py +++ b/backend/tests/unit/access/test_invite.py @@ -11,7 +11,7 @@ NotInvitedException, ) from app.modules.access.invite.invite_aggregate import Invite -from app.modules.access.schemas import UserRoleEnum +from app.modules.access.models import UserId, UserRoleEnum, User def then(given, when, expected_events): @@ -34,37 +34,53 @@ def test_send_invite(): # Setup fixed_datetime = datetime(2020, 6, 15, 12, 0, 0, tzinfo=timezone.utc) - full_name = 'test name' - email = 'test@email.com' + first_name = 'first' + middle_name = 'middle' + last_name = 'last' + email = 'test@example.com' invitee_role = UserRoleEnum.GUEST inviter_id = 'coordinator-id' inviter_role = UserRoleEnum.COORDINATOR - sent_at = fixed_datetime + requested_at = fixed_datetime + + expire_policy = lambda requested_at: requested_at + timedelta(days=7) - expire_policy = lambda sent_at: sent_at + timedelta(days=7) - token_gen = lambda email: 'token-' + email + coordinator = User.coordinator(first_name='coordinator', + middle_name=None, + last_name='rotanidrooc', + email='c@example.com') + user_service = lambda id: coordinator # Given given = [] # No prior events # When - when = lambda invite: invite.send_invite(full_name=full_name, - email=email, + when = lambda invite: invite.send_invite(email=email, + first_name=first_name, + middle_name=middle_name, + last_name=last_name, invitee_role=invitee_role, inviter_id=inviter_id, inviter_role=inviter_role, - sent_at=sent_at, - expire_policy=expire_policy) + requested_at=requested_at, + expire_policy=expire_policy, + user_service=user_service) # Then expected_events = [ - SendInviteRequestedDomainEvent(full_name=full_name, - email=email, - invitee_role=invitee_role, - inviter_id=inviter_id, - inviter_role=inviter_role, - sent_at=sent_at, - expire_at=expire_policy(sent_at)) + SendInviteRequestedDomainEvent( + email=email, + first_name=first_name, + middle_name=middle_name, + last_name=last_name, + invitee_role=invitee_role, + inviter_id=inviter_id, + inviter_first_name=coordinator.first_name, + inviter_middle_name=coordinator.middle_name, + inviter_last_name=coordinator.last_name, + inviter_role=inviter_role, + requested_at=requested_at, + expire_at=expire_policy(requested_at)) ] then(given, when, expected_events) @@ -74,34 +90,51 @@ def test_send_duplicate_pending_invite(): # Setup fixed_datetime = datetime(2020, 6, 15, 12, 0, 0, tzinfo=timezone.utc) email = 'test@email.com' - full_name = 'test name' + first_name = 'first' + middle_name = None + last_name = 'last' invitee_role = UserRoleEnum.COORDINATOR inviter_id = 'coordinator-id' inviter_role = UserRoleEnum.COORDINATOR - sent_at = fixed_datetime + requested_at = fixed_datetime expire_at = fixed_datetime + timedelta(days=7) - expire_policy = lambda sent_at: expire_at + expire_policy = lambda requested_at: expire_at + + coordinator = User.coordinator(first_name='coordinator', + middle_name=None, + last_name='rotanidrooc', + email='c@example.com') + user_service = lambda id: coordinator # Given given = [ - SendInviteRequestedDomainEvent(full_name=full_name, - email=email, - invitee_role=invitee_role, - inviter_id=inviter_id, - inviter_role=inviter_role, - sent_at=sent_at, - expire_at=expire_at) + SendInviteRequestedDomainEvent( + email=email, + first_name=first_name, + middle_name=middle_name, + last_name=last_name, + invitee_role=invitee_role, + inviter_id=inviter_id, + inviter_first_name=coordinator.first_name, + inviter_middle_name=coordinator.middle_name, + inviter_last_name=coordinator.last_name, + inviter_role=inviter_role, + requested_at=requested_at, + expire_at=expire_at) ] # When - when = lambda invite: invite.send_invite(full_name=full_name, - email=email, + when = lambda invite: invite.send_invite(email=email, + first_name=first_name, + middle_name=middle_name, + last_name=last_name, invitee_role=invitee_role, inviter_id=inviter_id, inviter_role=inviter_role, - sent_at=sent_at, - expire_policy=expire_policy) + requested_at=requested_at, + expire_policy=expire_policy, + user_service=user_service) # Then thenException(given, when, InviteAlreadySentException) @@ -112,30 +145,43 @@ def test_send_duplicate_sent_invite(): # Setup fixed_datetime = datetime(2020, 6, 15, 12, 0, 0, tzinfo=timezone.utc) email = 'test@email.com' - full_name = 'test name' + first_name = 'first' + middle_name = None + last_name = 'last' invitee_role = UserRoleEnum.COORDINATOR inviter_id = 'coordinator-id' inviter_role = UserRoleEnum.COORDINATOR - sent_at = fixed_datetime + requested_at = fixed_datetime expire_at = fixed_datetime + timedelta(days=7) - expire_policy = lambda sent_at: expire_at + expire_policy = lambda requested_at: expire_at + + coordinator = User.coordinator(first_name='coordinator', + middle_name=None, + last_name='rotanidrooc', + email='c@example.com') + user_service = lambda id: coordinator # Given given = [ InviteSentDomainEvent(email=email, - full_name=full_name, + first_name=first_name, + middle_name=middle_name, + last_name=last_name, expire_at=expire_at) ] # When - when = lambda invite: invite.send_invite(full_name=full_name, - email=email, + when = lambda invite: invite.send_invite(email=email, + first_name=first_name, + middle_name=middle_name, + last_name=last_name, invitee_role=invitee_role, inviter_id=inviter_id, inviter_role=inviter_role, - sent_at=sent_at, - expire_policy=expire_policy) + requested_at=requested_at, + expire_policy=expire_policy, + user_service=user_service) # Then thenException(given, when, InviteAlreadySentException) @@ -145,14 +191,18 @@ def test_invite_accepted(): # Setup email = 'test@email.com' - full_name = 'test name' + first_name = 'first' + middle_name = None + last_name = 'last' expire_at = datetime.now(timezone.utc) + timedelta(days=7) accepted_at = datetime.now(timezone.utc) # Given given = [ InviteSentDomainEvent(email=email, - full_name=full_name, + first_name=first_name, + middle_name=middle_name, + last_name=last_name, expire_at=expire_at) ] diff --git a/backend/tests/unit/core/test_event_store.py b/backend/tests/unit/core/test_event_store.py index 8a2b067eb..59c3bdc0b 100644 --- a/backend/tests/unit/core/test_event_store.py +++ b/backend/tests/unit/core/test_event_store.py @@ -1,5 +1,7 @@ from dataclasses import dataclass -from typing import Any +from datetime import datetime +from decimal import Decimal +import uuid from app.core.event_store import DomainEvent, InMemoryEventStore from app.core.sa_event_store import SqlAlchemyEventStore @@ -8,23 +10,30 @@ @dataclass(frozen=True) class Event1(DomainEvent): test: str - - @classmethod - def from_dict(cls, data: dict[str, Any]): - return cls(test=data['test']) + date: datetime + id: uuid.UUID + values: set + amount: Decimal + complex_num: complex + data: bytes @dataclass(frozen=True) class Event2(DomainEvent): test: int - @classmethod - def from_dict(cls, data: dict[str, Any]): - return cls(test=int(data['test'])) - def test_in_memory_event_store(): - events = [Event1("x"), Event2(1)] + events = [ + Event1(test="x", + date=datetime.now(), + id=uuid.uuid4(), + values=set([1, 2, 3]), + amount=Decimal("2.34"), + complex_num=complex("1+2j"), + data="test".encode("utf8")), + Event2(test=1) + ] stream_id = "test-stream" event_store = InMemoryEventStore() @@ -47,7 +56,16 @@ def test_in_memory_event_store(): def test_sqlalchemy_event_store(session_factory): - events = [Event1("x"), Event2(1)] + events = [ + Event1(test="x", + date=datetime.now(), + id=uuid.uuid4(), + values=set([1, 2, 3]), + amount=Decimal("2.34"), + complex_num=complex("1+2j"), + data="test".encode("utf8")), + Event2(test=1) + ] stream_id_1 = "test-stream-1" stream_id_2 = "test-stream-2" From 8144cd02a2091d7a0f131dea3602c52b9986a32b Mon Sep 17 00:00:00 2001 From: "Mr. Paul" Date: Thu, 7 Nov 2024 22:25:22 -0800 Subject: [PATCH 8/8] Event source invite This commit includes more updates to the event sourced invite flow. The sign in code was updated to use form data rather than json. --- backend/app/main.py | 2 + backend/app/modules/access/auth_controller.py | 12 +++--- .../modules/access/invite/invite_aggregate.py | 4 ++ backend/app/modules/access/schemas.py | 8 ++-- backend/app/modules/access/user_repo.py | 43 ++++++++++++++----- frontend/src/pages/authentication/SignIn.tsx | 4 +- .../CoordinatorDashboard.tsx | 28 ++++++------ frontend/src/services/auth.ts | 27 +++++++++--- 8 files changed, 88 insertions(+), 40 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 4b8990819..f540d6d91 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,7 @@ from app.modules.access.invite.contracts import ( SendInviteCommand, ProcessSentInviteCommand, FailedSentInviteCommand, SendInviteRequestedDomainEvent, InviteSentDomainEvent, + UserCreatedDomainEvent, InviteAcceptedDomainEvent, InviteSentFailedDomainEvent) from app.modules.access.invite.application_service import InviteService from app.modules.access.invite.processor import CognitoInviteUserProcessor @@ -54,6 +55,7 @@ async def lifespan(app: FastAPI): CognitoInviteUserProcessor(cognito_client, settings), ], InviteSentDomainEvent: [dashboard_users_projection], + UserCreatedDomainEvent: [user_repo], InviteAcceptedDomainEvent: [], InviteSentFailedDomainEvent: [dashboard_users_projection], } diff --git a/backend/app/modules/access/auth_controller.py b/backend/app/modules/access/auth_controller.py index 25a4af7db..b6bd0b455 100644 --- a/backend/app/modules/access/auth_controller.py +++ b/backend/app/modules/access/auth_controller.py @@ -18,9 +18,8 @@ from app.modules.access.crud import create_user, delete_user, get_user from app.modules.deps import (SettingsDep, DbSessionDep, CognitoIdpDep, - SecretHashFuncDep, CoordinatorDep, - requires_auth, allow_roles, - role_to_cognito_group_map) + SecretHashFuncDep, CoordinatorDep, requires_auth, + allow_roles, role_to_cognito_group_map) from app.modules.access.models import EmailAddress from app.modules.access.invite.contracts import SendInviteCommand, InviteAlreadySentException, NotInvitedException @@ -188,7 +187,7 @@ def current_session(request: Request, settings: SettingsDep, db: DbSessionDep, algorithms=["RS256"], options={"verify_signature": False}) - user = get_user(db, decoded_id_token['email']) + user = get_user(db, EmailAddress(decoded_id_token['email'])) try: auth_response = cognito_client.initiate_auth( @@ -322,7 +321,8 @@ def confirm_forgot_password( status_code=202, description="Invites a new user to join HUU.", ) -def invite(invite_request: InviteRequest, coordinator: CoordinatorDep) -> InviteResponse: +def invite(invite_request: InviteRequest, + coordinator: CoordinatorDep) -> InviteResponse: """Invite a new user to join HUU.""" inviter = coordinator @@ -438,7 +438,7 @@ def new_password(body: NewPasswordRequest, response: Response, options={"verify_signature": False}) try: - user = get_user(db, decoded_id_token['email']) + user = get_user(db, EmailAddress(decoded_id_token['email'])) if user is None: raise HTTPException(status_code=404, detail="User not found") except Exception as e: diff --git a/backend/app/modules/access/invite/invite_aggregate.py b/backend/app/modules/access/invite/invite_aggregate.py index 9aac62ab1..feb1aca5c 100644 --- a/backend/app/modules/access/invite/invite_aggregate.py +++ b/backend/app/modules/access/invite/invite_aggregate.py @@ -68,6 +68,10 @@ def when_InviteSentDomainEvent(self, event: InviteSentDomainEvent): self.invited = True + def when_UserCreatedDomainEvent(self, event: UserCreatedDomainEvent): + """Update the state of an Invite.""" + pass + def when_InviteAcceptedDomainEvent(self, event: InviteAcceptedDomainEvent): """Update state of an Invite.""" self.accepted_at = event.accepted_at diff --git a/backend/app/modules/access/schemas.py b/backend/app/modules/access/schemas.py index cbb0072b7..fb70c0527 100644 --- a/backend/app/modules/access/schemas.py +++ b/backend/app/modules/access/schemas.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic import BaseModel, ConfigDict, EmailStr, Field from .models import UserId, UserRoleEnum @@ -6,8 +8,8 @@ class UserBase(BaseModel): email: EmailStr first_name: str - middle_name: str | None = None - last_name: str | None = None + middle_name: Optional[str] = None + last_name: str class UserCreate(UserBase): @@ -55,7 +57,7 @@ class ConfirmForgotPasswordResponse(BaseModel): class InviteRequest(BaseModel): email: EmailStr firstName: str - middleName: str + middleName: Optional[str] = None lastName: str role: UserRoleEnum = UserRoleEnum.GUEST diff --git a/backend/app/modules/access/user_repo.py b/backend/app/modules/access/user_repo.py index 01e141de8..bfc53d2a5 100644 --- a/backend/app/modules/access/user_repo.py +++ b/backend/app/modules/access/user_repo.py @@ -1,4 +1,10 @@ -from app.modules.access.models import UserId, User, Role, UserRoleEnum +import logging + +from app.core.interfaces import DomainEvent +from app.modules.access.models import EmailAddress, UserId, User, UserRoleEnum +from app.modules.access.invite.contracts import UserCreatedDomainEvent + +log = logging.Logger(__name__) class UserRepository: @@ -6,17 +12,34 @@ class UserRepository: def __init__(self, session_factory): self.session_factory = session_factory + def mutate(self, domain_event: DomainEvent): + """Update the projection based on the domain event.""" + method = getattr(self, 'when_' + domain_event.__class__.__name__) + if method: + method(domain_event) + else: + log.warn( + f"when_{domain_event.__class__.__name__} not implemented.") + + def when_UserCreatedDomainEvent(self, e: UserCreatedDomainEvent): + """Update users.""" + if not self.get_user_by_id(e.user_id): + self.add_user(e.user_id, e.email, e.role, e.first_name, + e.middle_name, e.last_name) + def add_user(self, - email: str, + user_id: UserId, + email: EmailAddress, role: UserRoleEnum, - firstName: str, - middleName: str = None, - lastName: str = None) -> User: - new_user = User(email=email, - firstName=firstName, - middleName=middleName, - lastName=lastName, - role=role.value) + first_name: str, + middle_name: str = None, + last_name: str = None) -> User: + new_user = User(user_id=user_id, + email=email, + first_name=first_name, + middle_name=middle_name, + last_name=last_name, + role=role) with self.session_factory.begin() as session: session.add(new_user) diff --git a/frontend/src/pages/authentication/SignIn.tsx b/frontend/src/pages/authentication/SignIn.tsx index f8799abbf..68080f65b 100644 --- a/frontend/src/pages/authentication/SignIn.tsx +++ b/frontend/src/pages/authentication/SignIn.tsx @@ -52,11 +52,11 @@ export const SignIn = () => { password, }).unwrap(); - const {user, token} = response; + const {user, access_token: token} = response; dispatch(setCredentials({user, token})); - navigate(redirectsByRole[user.role.type]); + navigate(redirectsByRole[user.role]); } catch (err) { if (isFetchBaseQueryError(err)) { // you can access all properties of `FetchBaseQueryError` here diff --git a/frontend/src/pages/coordinator-dashboard/CoordinatorDashboard.tsx b/frontend/src/pages/coordinator-dashboard/CoordinatorDashboard.tsx index fd238523e..5b8967655 100644 --- a/frontend/src/pages/coordinator-dashboard/CoordinatorDashboard.tsx +++ b/frontend/src/pages/coordinator-dashboard/CoordinatorDashboard.tsx @@ -19,19 +19,23 @@ import { } from '../../services/coordinator'; import { GuestInviteButton, - LoadingComponent, + // LoadingComponent, } from '../../features/coordinator-dashboard'; const columns: GridColDef[] = [ { - field: 'userName', + field: 'name', headerName: 'Applicant', flex: 1, }, - {field: 'userType', headerName: 'Type'}, - {field: 'caseStatus', headerName: 'Status'}, - {field: 'coordinatorName', headerName: 'Coordinator', flex: 1}, - {field: 'lastUpdated', headerName: 'Updated', flex: 1}, + { + field: 'role', + headerName: 'Type', + renderCell: params => params.value.toUpperCase(), + }, + {field: 'status', headerName: 'Status', flex: 1}, + {field: 'coordinator_name', headerName: 'Coordinator', flex: 1}, + {field: 'updated', headerName: 'Updated', flex: 1}, { field: 'notes', headerName: 'Notes', @@ -59,20 +63,20 @@ export const CoordinatorDashboard = () => { if (value === 0) { return row; } else if (value === 1) { - return row.userType === 'GUEST'; + return row.role === 'guest'; } else if (value === 2) { - return row.userType === 'HOST'; + return row.role === 'host'; } }); const totalAppUsers = dashboardDataItems.filter( - row => ['GUEST', 'HOST'].indexOf(row.userType) >= 0, + row => ['guest', 'host'].indexOf(row.role) >= 0, ).length; const totalGuests = dashboardDataItems.filter( - row => row.userType === 'GUEST', + row => row.role === 'guest', ).length; const totalHosts = dashboardDataItems.filter( - row => row.userType === 'HOST', + row => row.role === 'host', ).length; const handleChange = (event: React.SyntheticEvent, newValue: number) => { @@ -163,6 +167,7 @@ export const CoordinatorDashboard = () => { disableRowSelectionOnClick rows={dashboardData ? dashboardData : []} columns={columns} + getRowId={row => row.user_id != -1 || row.email} initialState={{ pagination: { paginationModel: { @@ -172,7 +177,6 @@ export const CoordinatorDashboard = () => { }} slots={{ pagination: CustomPagination, - noRowsOverlay: LoadingComponent, }} sx={{ height: '538.75px', diff --git a/frontend/src/services/auth.ts b/frontend/src/services/auth.ts index 4a9ad9e63..346496bf1 100644 --- a/frontend/src/services/auth.ts +++ b/frontend/src/services/auth.ts @@ -16,7 +16,8 @@ export interface SignUpRequest { export interface SignInResponse { user: User; - token: string; + access_token: string; + token_type: string; } export interface SignInRequest { @@ -64,6 +65,12 @@ export interface ResendConfirmationCodeResponse { } // /auth/resend_confirmation_code +const encodeFormData = data => { + return Object.keys(data) + .map(key => encodeURIComponent(key) + '=' + encodeURIComponent(data[key])) + .join('&'); +}; + const authApi = api.injectEndpoints({ endpoints: build => ({ signUp: build.mutation({ @@ -75,12 +82,18 @@ const authApi = api.injectEndpoints({ }), }), signIn: build.mutation({ - query: credentials => ({ - url: 'auth/signin', - method: 'POST', - withCredentials: true, - body: credentials, - }), + query: credentials => { + const {email: username, password} = credentials; + return { + url: 'auth/signin', + method: 'POST', + withCredentials: true, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: encodeFormData({username, password, grant_type: 'password'}), + }; + }, }), googleSignUp: build.mutation({ query: data => {