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 new file mode 100644 index 000000000..dcfc7e381 --- /dev/null +++ b/backend/app/core/event_store.py @@ -0,0 +1,87 @@ +from abc import abstractmethod +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 + + +@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) -> DomainEventStream: + raise NotImplementedError + + @abstractmethod + def append(self, stream_id: Identity, new_events: list[DomainEvent], + expected_version: int): + raise NotImplementedError + + +class InMemoryEventStore: + + @dataclass + class EventStreamEntry: + stream_id: str + stream_version: int + event_data: str + meta_data: dict[str, Any] + stored_at: datetime + + def __init__(self): + self.events: dict[int, list[self.EventStreamEntry]] = {} + + def fetch(self, stream_id: Identity) -> DomainEventStream: + stream = DomainEventStream(version=0, events=[]) + + for stream_entry in self.events.get(stream_id, []): + stream.version = stream_entry.stream_version + stream.events.append( + self._deserialize_event(json.loads(stream_entry.event_data))) + + return stream + + def append(self, stream_id: Identity, new_events: list[DomainEvent], + expected_version: int): + stream_entries = self.events.get(stream_id, []) + + version = len(stream_entries) + if version != expected_version: + raise AppendOnlyStoreConcurrencyException( + f"version={version}, expected={expected_version}") + + stream_entries.extend([ + self.EventStreamEntry( + 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[stream_id] = stream_entries + + 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..ffff864b6 --- /dev/null +++ b/backend/app/core/interfaces.py @@ -0,0 +1,134 @@ +"""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 dataclasses import dataclass, asdict, replace, fields +from datetime import datetime, date, time +import decimal +from typing import Any, Type, TypeVar +import uuid + + +class Identity: + """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 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. + + This is a marker class to identify classes as being Commands. + """ + + +T = TypeVar("T", bound="DomainEvent") + + +class DomainEvent: + """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'])) + """ + 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': 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 new file mode 100644 index 000000000..e103d9b32 --- /dev/null +++ b/backend/app/core/sa_event_store.py @@ -0,0 +1,125 @@ +"""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 +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 app.core.event_store import (AppendOnlyStoreConcurrencyException, + DomainEvent, DomainEventStream) +import app.core.message_bus as message_bus + + +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_factory: Session): + """Instantiate the Event Store using a SQLAlchemy Session factory.""" + if session_factory is None: + raise ValueError( + "A Session factory is required to construct this Event Store.") + + self.session_factory = session_factory + + def fetch(self, stream_id: Identity) -> DomainEventStream: + """Fetch the event stream for the given stream.""" + stream = DomainEventStream(version=0, events=[]) + + statement = select(EventStreamEntry.stream_version, + EventStreamEntry.event_data).filter_by( + stream_id=str(stream_id)).order_by( + EventStreamEntry.stream_version) + with self.session_factory() as session: + stream_entries = session.execute(statement).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. + """ + 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.""" + 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/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..f540d6d91 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,10 +1,25 @@ -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, + UserCreatedDomainEvent, + 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 +31,35 @@ 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], + UserCreatedDomainEvent: [user_repo], + 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..b6bd0b455 100644 --- a/backend/app/modules/access/auth_controller.py +++ b/backend/app/modules/access/auth_controller.py @@ -1,23 +1,29 @@ +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, - 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 +import app.core.message_bus as message_bus router = APIRouter() @@ -30,26 +36,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 +66,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 +93,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 +137,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 +168,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): @@ -184,16 +187,18 @@ def current_session( 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( 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 +215,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 +229,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 +259,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 +281,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 +316,43 @@ 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'] @@ -464,19 +438,14 @@ def new_password( 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: - 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 new file mode 100644 index 000000000..bdaf782a4 --- /dev/null +++ b/backend/app/modules/access/invite/contracts.py @@ -0,0 +1,135 @@ +"""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 + + +@dataclass(frozen=True) +class InviteId(Identity): + """The identity of an Invite.""" + + 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.""" + + first_name: str + middle_name: str | None + last_name: str + email: EmailAddress + invitee_role: UserRoleEnum + 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: 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 + requested_at: datetime + expire_at: datetime + + +@dataclass +class InviteSentDomainEvent(DomainEvent): + """An Invite domain event.""" + + 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: EmailAddress + accepted_at: datetime + + +@dataclass +class InviteSentFailedDomainEvent(DomainEvent): + """An Invite failed to send.""" + + email: EmailAddress + + +############################################################################### +# Exceptions +############################################################################### + + +class InviteAlreadySentException(Exception): + """Invite was already sent.""" + + pass + + +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 new file mode 100644 index 000000000..feb1aca5c --- /dev/null +++ b/backend/app/modules/access/invite/invite_aggregate.py @@ -0,0 +1,165 @@ +"""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, +) + + +class InviteState: + """Holds the state of the Invite aggregate.""" + + 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 + + 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_SendInviteRequestedDomainEvent( + self, event: SendInviteRequestedDomainEvent): + """Update the state of an Invite.""" + self.email = event.email + 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.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_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 + + def when_InviteSentFailedDomainEvent(self, + event: InviteSentFailedDomainEvent): + """Update state of an Invite.""" + pass + + +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) + + @property + def changes(self): + """See a view into the events that cause state changes.""" + return self._changes + + 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, 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) + + 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, user_id: UserId, email: EmailAddress, + sent_at: datetime): + """Process a sent invite.""" + 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.""" + if not self._state.invited: + raise NotInvitedException(f"{email} was not invited.") + + 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 226ed6af3..99124bad6 100644 --- a/backend/app/modules/access/models.py +++ b/backend/app/modules/access/models.py @@ -1,37 +1,150 @@ """Model.""" -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 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 from app.core.db import Base +from app.core.interfaces import Identity, ValueObject -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) +class InvalidEmailError(Exception): + pass + + +@dataclass(frozen=True) +class EmailAddress(ValueObject, str): + """Represent a valid email address.""" + + 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, + allow_quoted_local=True) + + return cls(emailinfo.normalized) + + except EmailNotValidError as e: + raise InvalidEmailError(e) + + def __eq__(self, o): + if self is o: + return True + + if str(o) == self.email: + return True + + return False + + def __str__(self): + return self.email + + +class EmailAddressType(TypeDecorator): + impl = String # Use String or another appropriate base SQL type + cache_ok = True - role = relationship("Role", back_populates="users") + 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 - @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() + 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__ = "role" - id = Column(Integer, primary_key=True, index=True) - type = Column(String, nullable=False, unique=True) + __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) - users = relationship("User", back_populates="role") \ No newline at end of file + @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..fb70c0527 100644 --- a/backend/app/modules/access/schemas.py +++ b/backend/app/modules/access/schemas.py @@ -1,27 +1,15 @@ -from pydantic import BaseModel, ConfigDict, EmailStr +from typing import Optional -from enum import Enum +from pydantic import BaseModel, ConfigDict, EmailStr, Field - -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: Optional[str] = None + last_name: str class UserCreate(UserBase): @@ -30,8 +18,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 +31,9 @@ class UserSignInRequest(BaseModel): class UserSignInResponse(BaseModel): user: User - token: str + access_token: str + id_token: str + token_type: str class RefreshTokenResponse(BaseModel): @@ -67,51 +57,27 @@ class ConfirmForgotPasswordResponse(BaseModel): class InviteRequest(BaseModel): email: EmailStr firstName: str - middleName: str + middleName: Optional[str] = None 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..bfc53d2a5 100644 --- a/backend/app/modules/access/user_repo.py +++ b/backend/app/modules/access/user_repo.py @@ -1,54 +1,74 @@ -from app.modules.access.models import User, Role -from app.modules.access.user_roles import UserRole +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: - def __init__(self, session): - self.session = session + 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 _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 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, - role: UserRole, - 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() + user_id: UserId, + email: EmailAddress, + role: UserRoleEnum, + 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) 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_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 new file mode 100644 index 000000000..49f61e9cf --- /dev/null +++ b/backend/tests/unit/access/test_invite.py @@ -0,0 +1,234 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from app.modules.access.invite.contracts import ( + InviteId, + SendInviteRequestedDomainEvent, + InviteSentDomainEvent, + InviteAcceptedDomainEvent, + InviteAlreadySentException, + NotInvitedException, +) +from app.modules.access.invite.invite_aggregate import Invite +from app.modules.access.models import UserId, UserRoleEnum, User + + +def then(given, when, expected_events): + + invite = Invite(given) + when(invite) + + assert expected_events == invite.changes + assert invite._state.pending_send_invite or invite._state.invited + + +def thenException(given, when, expected_exception_class): + + invite = Invite(given) + with pytest.raises(expected_exception_class): + when(invite) + + +def test_send_invite(): + + # Setup + fixed_datetime = datetime(2020, 6, 15, 12, 0, 0, tzinfo=timezone.utc) + 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 + requested_at = fixed_datetime + + expire_policy = lambda requested_at: requested_at + timedelta(days=7) + + 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(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, + requested_at=requested_at, + expire_policy=expire_policy, + user_service=user_service) + + # Then + expected_events = [ + 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) + + +def test_send_duplicate_pending_invite(): + + # Setup + fixed_datetime = datetime(2020, 6, 15, 12, 0, 0, tzinfo=timezone.utc) + email = 'test@email.com' + first_name = 'first' + middle_name = None + last_name = 'last' + invitee_role = UserRoleEnum.COORDINATOR + inviter_id = 'coordinator-id' + inviter_role = UserRoleEnum.COORDINATOR + requested_at = fixed_datetime + expire_at = fixed_datetime + timedelta(days=7) + + 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( + 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(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, + requested_at=requested_at, + expire_policy=expire_policy, + user_service=user_service) + + # 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' + first_name = 'first' + middle_name = None + last_name = 'last' + invitee_role = UserRoleEnum.COORDINATOR + inviter_id = 'coordinator-id' + inviter_role = UserRoleEnum.COORDINATOR + requested_at = fixed_datetime + expire_at = fixed_datetime + timedelta(days=7) + + 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, + first_name=first_name, + middle_name=middle_name, + last_name=last_name, + expire_at=expire_at) + ] + + # When + 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, + requested_at=requested_at, + expire_policy=expire_policy, + user_service=user_service) + + # Then + thenException(given, when, InviteAlreadySentException) + + +def test_invite_accepted(): + + # Setup + email = 'test@email.com' + 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, + first_name=first_name, + middle_name=middle_name, + last_name=last_name, + expire_at=expire_at) + ] + + # When + when = lambda invite: invite.accept_invite(email=email, + accepted_at=accepted_at) + + # Then + expected_events = [ + InviteAcceptedDomainEvent(email=email, accepted_at=accepted_at) + ] + then(given, when, expected_events) + + +def test_uninvited_invite_accepted(): + + # Setup + email = 'test@email.com' + accepted_at = datetime.now(timezone.utc) + + # Given + given = [] + + # When + when = lambda invite: invite.accept_invite(email=email, + accepted_at=datetime) + + # Then + thenException(given, when, NotInvitedException) 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..59c3bdc0b --- /dev/null +++ b/backend/tests/unit/core/test_event_store.py @@ -0,0 +1,121 @@ +from dataclasses import dataclass +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 + + +@dataclass(frozen=True) +class Event1(DomainEvent): + test: str + date: datetime + id: uuid.UUID + values: set + amount: Decimal + complex_num: complex + data: bytes + + +@dataclass(frozen=True) +class Event2(DomainEvent): + test: int + + +def test_in_memory_event_store(): + 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() + + 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 + + +def test_sqlalchemy_event_store(session_factory): + 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" + + 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 + + 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) + + 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) + + 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 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 => {