diff --git a/backend/api/events.py b/backend/api/events.py deleted file mode 100644 index fec67527f..000000000 --- a/backend/api/events.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Event API - -Event routes are used to create, retrieve, and update Events.""" - -from fastapi import APIRouter, Depends, HTTPException - -from ..services.event import EventService -from ..models.event import Event -from ..models.event_details import EventDetails -from ..api.authentication import registered_user -from ..models.user import User - -__authors__ = ["Ajay Gandecha", "Jade Keegan", "Brianna Ta", "Audrey Toney"] -__copyright__ = "Copyright 2023" -__license__ = "MIT" - -api = APIRouter(prefix="/api/events") -openapi_tags = { - "name": "Events", - "description": "Create, update, delete, and retrieve CS Events.", -} - - -@api.get("", response_model=list[EventDetails], tags=["Events"]) -def get_events(event_service: EventService = Depends()) -> list[EventDetails]: - """ - Get all events - - Returns: - list[Event]: All `Event`s in the `Event` database table - """ - return event_service.all() - - -@api.get("/organization/{slug}", response_model=list[EventDetails], tags=["Events"]) -def get_events_by_organization( - slug: str, event_service: EventService = Depends() -) -> list[EventDetails]: - """ - Get all events from an organization - - Parameters: - slug: a valid str representing a unique Organization - event_service: a valid EventService - - Returns: - list[EventDetails]: All `EventDetails`s in the `Event` database table from a specific organization - """ - return event_service.get_events_by_organization(slug) - - -@api.post("", response_model=EventDetails, tags=["Events"]) -def new_event( - event: Event, - subject: User = Depends(registered_user), - event_service: EventService = Depends(), -) -> EventDetails: - """ - Create event - - Parameters: - event: a valid Event model - subject: a valid User model representing the currently logged in User - event_service: a valid EventService - - Returns: - EventDetails: latest iteration of the created or updated event after changes made - """ - return event_service.create(subject, event) - - -@api.get( - "/{id}", - responses={404: {"model": None}}, - response_model=EventDetails, - tags=["Events"], -) -def get_event_by_id(id: int, event_service: EventService = Depends()) -> EventDetails: - """ - Get event with matching id - - Parameters: - id: an int representing a unique Event ID - event_service: a valid EventService - - Returns: - EventDetails: a valid EventDetails model corresponding to the given event id - """ - return event_service.get_by_id(id) - - -@api.put( - "", responses={404: {"model": None}}, response_model=EventDetails, tags=["Events"] -) -def update_event( - event: EventDetails, - subject: User = Depends(registered_user), - event_service: EventService = Depends(), -) -> EventDetails: - """ - Update event - - Parameters: - event: a valid Event model - subject: a valid User model representing the currently logged in User - event_service: a valid EventService - - Returns: - EventDetails: a valid EventDetails model representing the updated Event - """ - return event_service.update(subject, event) - - -@api.delete("/{id}", tags=["Events"]) -def delete_event( - id: int, - subject: User = Depends(registered_user), - event_service: EventService = Depends(), -): - """ - Delete event based on id - - Parameters: - id: an int representing a unique event ID - subject: a valid User model representing the currently logged in User - event_service: a valid EventService - """ - event_service.delete(subject, id) diff --git a/backend/api/events/__init__.py b/backend/api/events/__init__.py new file mode 100644 index 000000000..333c0c2f4 --- /dev/null +++ b/backend/api/events/__init__.py @@ -0,0 +1,15 @@ +from fastapi import Request +from fastapi.responses import JSONResponse +from backend.services.exceptions import EventRegistrationException + + +# FastAPI Middleware Exception Handler for ReservationExceptions +def _event_registration_exception_handler( + request: Request, e: EventRegistrationException +): + return JSONResponse(status_code=422, content={"message": str(e)}) + + +exception_handlers = [ + (EventRegistrationException, _event_registration_exception_handler) +] diff --git a/backend/api/events/events.py b/backend/api/events/events.py new file mode 100644 index 000000000..a525c1669 --- /dev/null +++ b/backend/api/events/events.py @@ -0,0 +1,413 @@ +"""Event API + +Event routes are used to create, retrieve, and update Events.""" + +from fastapi import APIRouter, Depends, HTTPException +from datetime import datetime, timedelta +from typing import Sequence +from backend.models.event_member import EventMember +from backend.models.pagination import Paginated, PaginationParams + +from backend.services.organization import OrganizationService + +from ...services.event import EventService +from ...services.user import UserService +from ...services.exceptions import ResourceNotFoundException, UserPermissionException +from ...models.event import DraftEvent +from ...models.event_details import EventDetails +from ...models.coworking.time_range import TimeRange +from ...api.authentication import registered_user +from ...models.user import User + +__authors__ = [ + "Ajay Gandecha", + "Jade Keegan", + "Brianna Ta", + "Audrey Toney", + "Kris Jordan", +] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + +api = APIRouter(prefix="/api/events") +openapi_tags = { + "name": "Events", + "description": "Create, update, delete, and retrieve CS Events.", +} + + +@api.get("", response_model=list[EventDetails], tags=["Events"]) +def get_events( + subject: User = Depends(registered_user), event_service: EventService = Depends() +) -> list[EventDetails]: + """ + Get all events + + Args: + subject: a valid User model representing the currently logged in User + event_service: a valid EventService + + Returns: + list[EventDetails]: All `EventDetails`s in the `Event` database table + """ + return event_service.all(subject) + + +@api.get("/range", response_model=list[EventDetails], tags=["Events"]) +def get_events_in_time_range( + subject: User = Depends(registered_user), + start: datetime | None = None, + end: datetime | None = None, + event_service: EventService = Depends(), +) -> list[EventDetails]: + """ + Get all events in the time range + + Args: + subject: a valid User model representing the currently logged in User + start (optional): a datetime object representing the start time of the range. + end (optional): a datetime object representing the start time of the range. + event_service: a valid EventService + + Returns: + list[EventDetails]: All `EventDetails`s in the `Event` database table + """ + start = datetime.now() if start is None else start + end = datetime.now() + timedelta(days=365) if end is None else end + time_range = TimeRange(start=start, end=end) + + return event_service.get_events_in_time_range(time_range, subject) + + +@api.get("/organization/{slug}", response_model=list[EventDetails], tags=["Events"]) +def get_events_by_organization( + slug: str, + subject: User = Depends(registered_user), + event_service: EventService = Depends(), + organization_service: OrganizationService = Depends(), +) -> list[EventDetails]: + """ + Get all events from an organization + + Args: + slug: a valid str representing a unique Organization + subject: a valid User model representing the currently logged in User + event_service: a valid EventService + orgnaization_service: a valid OrganizationService + + Returns: + list[EventDetails]: All `EventDetails`s in the `Event` database table from a specific organization + """ + organization = organization_service.get_by_slug(slug) + return event_service.get_events_by_organization(organization, subject) + + +@api.get( + "/{id}", + responses={404: {"model": None}}, + response_model=EventDetails, + tags=["Events"], +) +def get_event_by_id( + id: int, + subject: User = Depends(registered_user), + event_service: EventService = Depends(), +) -> EventDetails: + """ + Get event with matching id + + Args: + id: an int representing a unique Event ID + subject: a valid User model representing the currently logged in User + event_service: a valid EventService + + Returns: + EventDetails: a valid EventDetails model corresponding to the given event id + """ + return event_service.get_by_id(id, subject) + + +@api.get("/unauthenticated", response_model=list[EventDetails], tags=["Events"]) +def get_events_unauthenticated( + event_service: EventService = Depends(), +) -> list[EventDetails]: + """ + Get all events for unauthenticated users + + Args: + event_service: a valid EventService + + Returns: + list[EventDetails]: All `EventDetails`s in the `Event` database table + """ + # For some reason this API route always returns "Not authenticated" regardless of the service method in it, + # even for the Root user. It isn't actually used since I opted for the time range version, but still unsure + # why it's not working. + raise NotImplementedError + # return event_service.all() + + +@api.get("/range/unauthenticated", response_model=list[EventDetails], tags=["Events"]) +def get_events_in_time_range_unauthenticated( + start: datetime | None = None, + end: datetime | None = None, + event_service: EventService = Depends(), +) -> list[EventDetails]: + """ + Get all events in the time range for unauthenticated users + + Args: + start (optional): a datetime object representing the start time of the range. + end (optional): a datetime object representing the start time of the range. + event_service: a valid EventService + + Returns: + list[EventDetails]: All `EventDetails`s in the `Event` database table + """ + start = datetime.now() if start is None else start + end = datetime.now() + timedelta(days=365) if end is None else end + time_range = TimeRange(start=start, end=end) + + return event_service.get_events_in_time_range(time_range) + + +@api.get( + "/organization/{slug}/unauthenticated", + response_model=list[EventDetails], + tags=["Events"], +) +def get_events_by_organization_unauthenticated( + slug: str, + event_service: EventService = Depends(), + organization_service: OrganizationService = Depends(), +) -> list[EventDetails]: + """ + Get all events from an organization for unauthenticated users + + Args: + slug: a valid str representing a unique Organization + event_service: a valid EventService + organization_service: a valid OrganizationService + + Returns: + list[EventDetails]: All `EventDetails`s in the `Event` database table from a specific organization + """ + organization = organization_service.get_by_slug(slug) + return event_service.get_events_by_organization(organization) + + +@api.get( + "/{id}/unauthenticated", + responses={404: {"model": None}}, + response_model=EventDetails, + tags=["Events"], +) +def get_event_by_id_unauthenticated( + id: int, event_service: EventService = Depends() +) -> EventDetails: + """ + Get event with matching id for unauthenticated users + + Args: + id: an int representing a unique Event ID + event_service: a valid EventService + + Returns: + EventDetails: a valid EventDetails model corresponding to the given event id + """ + return event_service.get_by_id(id) + + +@api.post("", response_model=EventDetails, tags=["Events"]) +def new_event( + event: DraftEvent, + subject: User = Depends(registered_user), + event_service: EventService = Depends(), +) -> EventDetails: + """ + Create event + + Args: + event: a valid Event model + subject: a valid User model representing the currently logged in User + event_service: a valid EventService + + Returns: + EventDetails: latest iteration of the created or updated event after changes made + """ + return event_service.create(subject, event) + + +@api.put( + "", responses={404: {"model": None}}, response_model=EventDetails, tags=["Events"] +) +def update_event( + event: EventDetails, + subject: User = Depends(registered_user), + event_service: EventService = Depends(), +) -> EventDetails: + """ + Update event + + Args: + event: a valid Event model + subject: a valid User model representing the currently logged in User + event_service: a valid EventService + + Returns: + EventDetails: a valid EventDetails model representing the updated Event + """ + return event_service.update(subject, event) + + +@api.delete("/{id}", tags=["Events"]) +def delete_event( + id: int, + subject: User = Depends(registered_user), + event_service: EventService = Depends(), +): + """ + Delete event based on id + + Args: + id: an int representing a unique event ID + subject: a valid User model representing the currently logged in User + event_service: a valid EventService + """ + event_service.delete(subject, id) + + +@api.post("/{event_id}/registration", tags=["Events"]) +def register_for_event( + event_id: int, + user_id: int = -1, + subject: User = Depends(registered_user), + event_service: EventService = Depends(), + user_service: UserService = Depends(), +) -> EventMember: + """ + Register a user event based on the event ID. + + If the user_id parameter is not passed to the post method, we will use the + logged in user's ID as the user_id. Another user's ID is expected when a + user is being registered by an administrator. + + Args: + event_id: an int representing a unique event ID + user_id: (optional) an int representing the user being registered for an event + subject: a valid User model representing the currently logged in User + event_service: a valid EventService + + Returns: + EventRegistration details + """ + if user_id == -1 and subject.id is not None: + user = subject + else: + user = user_service.get_by_id(user_id) + + event: EventDetails = event_service.get_by_id(event_id, subject) + return event_service.register(subject, user, event) + + +@api.get("/{event_id}/registration", tags=["Events"]) +def get_event_registration_of_user( + event_id: int, + subject: User = Depends(registered_user), + event_service: EventService = Depends(), +) -> EventMember: + """ + Check the registration status of a user for an event, raise ResourceNotFound if unregistered. + + Args: + event_id: the int identifier of an Event + subject: the logged in user making the request + event_service: the backing service + """ + event: EventDetails = event_service.get_by_id(event_id, subject) + event_registration = event_service.get_registration(subject, subject, event) + if event_registration is None: + raise ResourceNotFoundException("You are not registered for this event") + else: + return event_registration + + +@api.get("/{event_id}/registrations", tags=["Events"]) +def get_event_registrations( + event_id: int, + subject: User = Depends(registered_user), + event_service: EventService = Depends(), +) -> Sequence[EventMember]: + """ + Get the registrations of an event. + + Args: + event_id: the int identifier of an Event + subject: the logged in user making the request + event_service: the backing service + + Returns: + Sequence[EventRegistration] + """ + return event_service.get_registrations_of_event( + subject, event_service.get_by_id(event_id, subject) + ) + + +@api.delete("/{event_id}/registration", tags=["Events"]) +def unregister_for_event( + event_id: int, + user_id: int = -1, + subject: User = Depends(registered_user), + event_service: EventService = Depends(), + user_service: UserService = Depends(), +): + """ + Unregister a user event based on the event ID + + Args: + event_id: an int representing a unique event ID + user_id: the int of the user whose registration is being deleted (optional) + subject: a valid User model representing the currently logged in User + event_service: EventService + user_service: UserService + """ + if user_id == -1 and subject.id is not None: + user = subject + else: + user = user_service.get_by_id(user_id) + + event: EventDetails = event_service.get_by_id(event_id, subject) + event_service.unregister(subject, user, event) + + +@api.get("/{event_id}/registrations/users", tags=["Events"]) +def get_registered_users_of_event( + event_id: int, + subject: User = Depends(registered_user), + event_service: EventService = Depends(), + page: int = 0, + page_size: int = 10, + order_by: str = "first_name", + filter: str = "", +) -> Paginated[User]: + """ + List registered users for an event via standard backend pagination query parameters. + + Args: + event_id: an int representing a unique Event + subject: a valid User model representing the currently logged in User + event_service: a valid EventService + + Returns: + Paginated[User]: All `User`s registered for an event in Paginated form + """ + try: + pagination_params = PaginationParams( + page=page, page_size=page_size, order_by=order_by, filter=filter + ) + return event_service.get_registered_users_of_event( + subject, event_id, pagination_params + ) + except UserPermissionException as e: + raise HTTPException(status_code=403, detail=str(e)) diff --git a/backend/entities/__init__.py b/backend/entities/__init__.py index fbf2a3052..cb2f4e42f 100644 --- a/backend/entities/__init__.py +++ b/backend/entities/__init__.py @@ -21,6 +21,7 @@ from .user_role_table import user_role_table from .organization_entity import OrganizationEntity from .event_entity import EventEntity +from .event_registration_entity import EventRegistrationEntity __authors__ = ["Kris Jordan"] __copyright__ = "Copyright 2023" diff --git a/backend/entities/event_entity.py b/backend/entities/event_entity.py index 8091cb280..cd40c56b6 100644 --- a/backend/entities/event_entity.py +++ b/backend/entities/event_entity.py @@ -5,7 +5,10 @@ from ..models.event_details import EventDetails from .entity_base import EntityBase from typing import Self -from ..models.event import Event +from ..models.event import DraftEvent, Event +from ..models.registration_type import RegistrationType +from ..models.user import User + from datetime import datetime __authors__ = ["Ajay Gandecha", "Jade Keegan", "Brianna Ta", "Audrey Toney"] @@ -33,12 +36,43 @@ class EventEntity(EntityBase): description: Mapped[str] = mapped_column(String) # Whether the event is public or not public: Mapped[bool] = mapped_column(Boolean) + # Maximim number of people who can register for the event + registration_limit: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # Whether or not registration is open + can_register: Mapped[bool] = mapped_column(Boolean, default=True) # Organization hosting the event # NOTE: This defines a one-to-many relationship between the organization and events tables. organization_id: Mapped[int] = mapped_column(ForeignKey("organization.id")) organization: Mapped["OrganizationEntity"] = relationship(back_populates="events") + # Registrations for the event + # NOTE: This is part of a many-to-many relationship between events and users, via the event registration table. + registrations: Mapped[list["EventRegistrationEntity"]] = relationship( + back_populates="event", cascade="all,delete" + ) + + @classmethod + def from_draft_model(cls, model: DraftEvent) -> Self: + """ + Class method that converts an `DraftEvent` model into a `EventEntity` + + Parameters: + - model (DraftEvent): Model to convert into an entity + Returns: + EventEntity: Entity created from model + """ + return cls( + name=model.name, + time=model.time, + location=model.location, + description=model.description, + public=model.public, + registration_limit=model.registration_limit, + can_register=model.can_register, + organization_id=model.organization_id, + ) + @classmethod def from_model(cls, model: Event) -> Self: """ @@ -56,16 +90,43 @@ def from_model(cls, model: Event) -> Self: location=model.location, description=model.description, public=model.public, + registration_limit=model.registration_limit, + can_register=model.can_register, organization_id=model.organization_id, ) - def to_model(self) -> Event: + def to_model(self, subject: User | None = None) -> Event: """ Converts a `EventEntity` object into a `Event` model object Returns: Event: `Event` object from the entity """ + attendees = [ + registration.to_flat_model() + for registration in self.registrations + if registration.registration_type == RegistrationType.ATTENDEE + ] + is_attendee = ( + len([attendee for attendee in attendees if attendee.id == subject.id]) > 0 + if subject is not None + else False + ) + + # Hide organizer info for unauthenticated users + organizers = [ + registration.to_flat_organizer_model() + for registration in self.registrations + if registration.registration_type == RegistrationType.ORGANIZER + ] + + is_organizer = ( + len([organizer for organizer in organizers if organizer.id == subject.id]) + > 0 + if subject is not None + else False + ) + return Event( id=self.id, name=self.name, @@ -73,35 +134,25 @@ def to_model(self) -> Event: location=self.location, description=self.description, public=self.public, + registration_limit=self.registration_limit, + can_register=self.can_register, organization_id=self.organization_id, + registration_count=len(attendees), + is_attendee=is_attendee, + attendees=attendees, + is_organizer=is_organizer, + organizers=organizers, ) - @classmethod - def from_details_model(cls, model: EventDetails): - """ - Class method that converts an `EventDetails` model into a `EventEntity` - - Parameters: - - model (EventDetails): Model to convert into an entity - Returns: - EventEntity: Entity created from model - """ - return cls( - id=model.id, - name=model.name, - time=model.time, - location=model.location, - description=model.description, - public=model.public, - organization_id=model.organization_id, - ) - - def to_details_model(self) -> EventDetails: + def to_details_model(self, subject: User | None = None) -> EventDetails: """Create a EventDetails model from an EventEntity, with permissions and members included. Returns: EventDetails: An EventDetails model for API usage. """ + + event = self.to_model(subject) + return EventDetails( id=self.id, name=self.name, @@ -109,6 +160,13 @@ def to_details_model(self) -> EventDetails: location=self.location, description=self.description, public=self.public, + registration_limit=self.registration_limit, + registration_count=event.registration_count, + can_register=self.can_register, organization_id=self.organization_id, organization=self.organization.to_model(), + is_attendee=event.is_attendee, + attendees=event.attendees, + is_organizer=event.is_organizer, + organizers=event.organizers, ) diff --git a/backend/entities/event_registration_entity.py b/backend/entities/event_registration_entity.py new file mode 100644 index 000000000..e918dd0ab --- /dev/null +++ b/backend/entities/event_registration_entity.py @@ -0,0 +1,123 @@ +"""Definition of SQLAlchemy table-backed object mapping entity for Event Registrations.""" + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.models.event_member import EventOrganizer + +from ..models import RegistrationType, EventMember +from .entity_base import EntityBase +from typing import Self +from ..models.event_registration import EventRegistration, NewEventRegistration +from sqlalchemy import Enum as SQLAlchemyEnum + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class EventRegistrationEntity(EntityBase): + """Serves as the database model schema defining the shape of the `EventRegistration` table + + This table is the association / join table to establish the many-to-many relationship + between the `user` and `event` tables. This allows many users to register for one event, and + users be registered for many events. + + To establish this relationship, this entity contains two primary key fields for each related + table. + """ + + # Name for the events table in the PostgreSQL database + __tablename__ = "event_registration" + + # Event for the current event registration + # NOTE: This is ultimately a join table for a many-to-many relationship + event_id: Mapped[int] = mapped_column(ForeignKey("event.id"), primary_key=True) + event: Mapped["EventEntity"] = relationship(back_populates="registrations") + + # User for the current event registration + # NOTE: This is ultimately a join table for a many-to-many relationship + user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), primary_key=True) + user: Mapped["UserEntity"] = relationship() + + # Type of relationship + registration_type: Mapped[RegistrationType] = mapped_column( + SQLAlchemyEnum(RegistrationType) + ) + + @classmethod + def from_model(cls, model: EventRegistration) -> Self: + """ + Class method that converts an `EventRegistration` model into a `EventRegistrationEntity` + + Parameters: + - model (EventRegistration): Model to convert into an entity + Returns: + EventRegistrationEntity: Entity created from model + """ + return cls( + event_id=model.event_id, + user_id=model.user_id, + event=model.event, + user=model.user, + registration_type=model.registration_type, + ) + + @classmethod + def from_new_model(cls, model: NewEventRegistration) -> Self: + """ + Class method that converts an `NewEventRegistration` model into a `EventRegistrationEntity` + + Parameters: + - model (NewEventRegistration): Model to convert into an entity + Returns: + EventRegistrationEntity: Entity created from model + """ + return cls( + event_id=model.event_id, + user_id=model.user_id, + registration_type=model.registration_type, + ) + + def to__model(self) -> EventRegistration: + """ + Converts an `EventRegistrationEntity` into an `EventRegistration` model object + to store registration information. + + Returns: + EventRegistration: `EventRegistration` object from the entity + """ + return EventRegistration( + event_id=self.event_id, + event=self.event, + user_id=self.user_id, + user=self.user, + registration_type=self.registration_type, + ) + + def to_flat_model(self) -> EventMember: + """ + Converts an `EventRegistrationEntity` into an `EventMember` model object + to store user ID. + + Returns: + EventMember: `EventMember` object from the entity + """ + return EventMember(id=self.user_id, registration_type=self.registration_type) + + def to_flat_organizer_model(self) -> EventMember: + """ + Converts an `EventRegistrationEntity` into an `EventMember` model object + to store user ID. + + Returns: + EventMember: `EventMember` object from the entity + """ + return EventOrganizer( + id=self.user_id, + first_name=self.user.first_name, + last_name=self.user.last_name, + pronouns=self.user.pronouns, + email=self.user.email, + registration_type=self.registration_type, + ) diff --git a/backend/main.py b/backend/main.py index e42cc0d88..167cde03a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,8 +5,12 @@ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from fastapi.middleware.gzip import GZipMiddleware + +from backend.services.coworking.reservation import ReservationException + +from .api.events import events + from .api import ( - events, health, organizations, static_files, @@ -19,7 +23,11 @@ from .api.academics import term, course, section from .api.admin import users as admin_users from .api.admin import roles as admin_roles -from .services.exceptions import UserPermissionException, ResourceNotFoundException +from .services.exceptions import ( + EventRegistrationException, + UserPermissionException, + ResourceNotFoundException, +) __authors__ = ["Kris Jordan"] __copyright__ = "Copyright 2023" @@ -93,12 +101,13 @@ def resource_not_found_exception_handler( # Add feature-specific exception handling middleware from .api import coworking +from .api import events -feature_exception_handlers = [coworking.exception_handlers] +feature_exception_handlers = [coworking.exception_handlers, events.exception_handlers] for feature_exception_handler in feature_exception_handlers: for exception, handler in feature_exception_handler: @app.exception_handler(exception) - def _handler_wrapper(request: Request, e: exception): + def _handler_wrapper(request: Request, e: Exception): return handler(request, e) diff --git a/backend/migrations/README b/backend/migrations/README index a31eae901..2ece7800f 100644 --- a/backend/migrations/README +++ b/backend/migrations/README @@ -3,25 +3,36 @@ the database is created and dropped with the `reset_demo` script. To create a migration: -1. Switch to main branch and pull latest from main. -2. Run delete database script -3. Run create database script (with `main`'s understanding of the schema) -4. Run the reset demo or test script to create tables -5. Stamp alembic at the latest revision: `alembic stamp head` -6. Switch to the branch you want to migrate -7. Run `alembic revision --autogenerate -m "migration message"` -8. Edit the contents of the generated migration to match the desired migration +0. Be sure you have pushed all changes to a remote branch +1. Switch to `main` +2. Run the generate migration script: + `python3 -m backend.script.generate_migration ` +3. Review the generated migration by looking at the new file added to `backend/migration/versions` +4. Edit the contents of the generated migration to match the desired migration Common issues: 1. Renamed tables shouldn't be created and dropped as new tables 2. Enums need to be explicitly dropped (see 3b3cd for an example) + 3. New fields that are non-nullable need default values (see 1952e for an example) -Test the migration: +Validate the migration: -8. Run `alembic upgrade head` to run the migration (all tables should be created) -9. Try using the app to validate functionality -10. Run `alembic downgrade head-1` to undo the migration and test rollback -11. Repeat 8 and 9 to confirm rollback and upgrade work as expected +1. Run `alembic upgrade head` to run the migration (all tables should be created) +2. Try using the app to validate functionality +3. Run `alembic downgrade head-1` to undo the migration and test rollback +4. Repeat 8 and 9 to confirm rollback and upgrade work as expected -On production deploy: +When things go wrong: -12. Run `alembic upgrade head` to run the migration on a pod in production \ No newline at end of file +If the initial upgrade/downgrade fail, you can try to fix the migration and run it/downgrade again. + +If the initial upgrade/downgrade succeeds, but the second upgrade fails, your best bet is typically to do the following: + +1. `git switch main` +2. `python3 -m backend.script.reset_testing` +3. `alembic stamp head` +4. `git switch ` +5. `alembic upgrade head` + +This sequence of steps gives you a clean slate to retry the upgrade/downgrade of the migration with. + +On production deploy run `alembic upgrade head` on a pod with the latest build. \ No newline at end of file diff --git a/backend/migrations/versions/1952e411745f_add_event_registration.py b/backend/migrations/versions/1952e411745f_add_event_registration.py new file mode 100644 index 000000000..9f4b2b42c --- /dev/null +++ b/backend/migrations/versions/1952e411745f_add_event_registration.py @@ -0,0 +1,65 @@ +"""Migration for feature/event-registration-frontend + +Revision ID: 1952e411745f +Revises: 3b3cd40813a5 +Create Date: 2023-12-31 12:21:27.507876 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "1952e411745f" +down_revision = "3b3cd40813a5" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "event_registration", + sa.Column("event_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column( + "registration_type", + sa.Enum("ATTENDEE", "ORGANIZER", name="registrationtype"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["event_id"], + ["event.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("event_id", "user_id"), + ) + op.add_column( + "event", + sa.Column( + "registration_limit", + sa.Integer(), + nullable=False, + default=0, + server_default=sa.text("0"), + ), + ) + op.add_column( + "event", + sa.Column( + "can_register", + sa.Boolean(), + nullable=False, + default=False, + server_default=sa.text("false"), + ), + ) + + +def downgrade() -> None: + op.drop_column("event", "can_register") + op.drop_column("event", "registration_limit") + op.drop_table("event_registration") + op.execute("DROP TYPE registrationtype") diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 20bcc44b4..def1f76fe 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -9,9 +9,15 @@ from .role_details import RoleDetails from .organization import Organization from .event import Event +from .event_member import EventMember from .event_details import EventDetails from .room import Room from .room_details import RoomDetails +from .event_registration import ( + EventRegistration, + NewEventRegistration, +) +from .registration_type import RegistrationType __authors__ = ["Kris Jordan"] __copyright__ = "Copyright 2023" diff --git a/backend/models/event.py b/backend/models/event.py index 2ace8a515..1503fcd2d 100644 --- a/backend/models/event.py +++ b/backend/models/event.py @@ -1,23 +1,42 @@ from pydantic import BaseModel from datetime import datetime +from .event_member import EventMember, EventOrganizer + __authors__ = ["Ajay Gandecha", "Jade Keegan", "Brianna Ta", "Audrey Toney"] __copyright__ = "Copyright 2023" __license__ = "MIT" -class Event(BaseModel): +class DraftEvent(BaseModel): """ - Pydantic model to represent an `Event`. + Pydantic model to represent an `Event` that has not been created yet. This model is based on the `EventEntity` model, which defines the shape of the `Event` database in the PostgreSQL database """ - id: int | None = None name: str time: datetime location: str description: str public: bool + registration_limit: int + can_register: bool organization_id: int + organizers: list[EventOrganizer] = [] + + +class Event(DraftEvent): + """ + Pydantic model to represent an `Event`. + + This model is based on the `EventEntity` model, which defines the shape + of the `Event` database in the PostgreSQL database + """ + + id: int + registration_count: int = 0 + is_attendee: bool = False + attendees: list[EventMember] = [] + is_organizer: bool = False diff --git a/backend/models/event_details.py b/backend/models/event_details.py index 6908f8259..3d2976485 100644 --- a/backend/models/event_details.py +++ b/backend/models/event_details.py @@ -4,8 +4,7 @@ class EventDetails(Event): """ - Pydantic model to represent an `Event`, including back-populated - relationship fields. + Pydantic model to represent an `Event`. This model is based on the `EventEntity` model, which defines the shape of the `Event` database in the PostgreSQL database. diff --git a/backend/models/event_member.py b/backend/models/event_member.py new file mode 100644 index 000000000..142179599 --- /dev/null +++ b/backend/models/event_member.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel +from .registration_type import RegistrationType + + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class EventMember(BaseModel): + """ + Pydantic model to represent the information about a user who is + registered for an event. + + This model is based on the `UserEntity` model, which defines the shape + of the `User` database in the PostgreSQL database + """ + + id: int | None + registration_type: RegistrationType + + +class EventOrganizer(EventMember): + """ + Pydantic model to represent the information about a user who is + registered for an event. + + This model is based on the `UserEntity` model, which defines the shape + of the `User` database in the PostgreSQL database + """ + + first_name: str + last_name: str + pronouns: str + email: str diff --git a/backend/models/event_registration.py b/backend/models/event_registration.py new file mode 100644 index 000000000..0f374a2f4 --- /dev/null +++ b/backend/models/event_registration.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel +from .event import Event +from .user import User +from .registration_type import RegistrationType + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class NewEventRegistration(BaseModel): + """ + Pydantic model to represent an `EventRegistration`. + + This model is based on the `EventRegistrationEntity` model, which + defines the shape of the `EventRegistration` table in the PostgreSQL database + + This model is needed for the creation of new registrations in the event service + """ + + event_id: int + user_id: int + registration_type: RegistrationType + + +class EventRegistration(NewEventRegistration): + """ + Pydantic model to represent an `EventRegistration`. + + This model is based on the `EventRegistrationEntity` model, which + defines the shape of the `EventRegistration` table in the PostgreSQL database + """ + + event: Event + user: User diff --git a/backend/models/registration_type.py b/backend/models/registration_type.py new file mode 100644 index 000000000..900a08227 --- /dev/null +++ b/backend/models/registration_type.py @@ -0,0 +1,16 @@ +"""Determines the type of an event registration.""" + +from enum import Enum + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class RegistrationType(Enum): + """ + Determines the type of an event registration. + """ + + ATTENDEE = 0 + ORGANIZER = 1 diff --git a/backend/models/user_details.py b/backend/models/user_details.py index 4ec7bdd6f..da033c25a 100644 --- a/backend/models/user_details.py +++ b/backend/models/user_details.py @@ -6,7 +6,7 @@ __license__ = "MIT" -class UserPermissions(User): +class UserDetails(User): """ Pydantic model to represent a `User`, including the permissions a user has. @@ -16,15 +16,3 @@ class UserPermissions(User): """ permissions: list["Permission"] = [] - - -class UserDetails(UserPermissions): - """ - Pydantic model to represent a `User`, including back-populated - relationship fields. - - This model is based on the `UserEntity` model, which defines the shape - of the `Event` database in the PostgreSQL database. - """ - - ... diff --git a/backend/services/event.py b/backend/services/event.py index 1728de4cf..078d6be59 100644 --- a/backend/services/event.py +++ b/backend/services/event.py @@ -2,19 +2,40 @@ The Event Service allows the API to manipulate event data in the database. """ +from typing import Sequence + from fastapi import Depends -from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy import func, select, or_ +from sqlalchemy.orm import Session, aliased +from backend.entities.user_entity import UserEntity +from backend.models.event_member import EventMember +from backend.models.organization_details import OrganizationDetails +from backend.models.pagination import Paginated, PaginationParams +from backend.models.registration_type import RegistrationType from backend.models.user import User from ..database import db_session -from backend.models.event import Event +from backend.models.event import Event, DraftEvent from backend.models.event_details import EventDetails -from ..entities import EventEntity, OrganizationEntity +from backend.models.coworking.time_range import TimeRange +from ..entities import ( + EventEntity, + EventRegistrationEntity, +) from .permission import PermissionService -from .exceptions import ResourceNotFoundException - -__authors__ = ["Ajay Gandecha", "Jade Keegan", "Brianna Ta", "Audrey Toney"] +from .exceptions import ( + ResourceNotFoundException, + EventRegistrationException, +) +from . import UserService + +__authors__ = [ + "Ajay Gandecha", + "Jade Keegan", + "Brianna Ta", + "Audrey Toney", + "Kris Jordan", +] __copyright__ = "Copyright 2023" __license__ = "MIT" @@ -26,31 +47,61 @@ def __init__( self, session: Session = Depends(db_session), permission: PermissionService = Depends(), + user_svc: UserService = Depends(), ): """Initializes the `EventService` session""" self._session = session self._permission = permission + self._user_svc = user_svc - def all(self) -> list[EventDetails]: + def all( + self, + subject: User | None = None, + ) -> list[EventDetails]: """ Retrieves all events from the table + Args: + subject: The User making the request. + Returns: list[EventDetails]: List of all `EventDetails` """ # Select all entries in `Event` table - query = select(EventEntity) - entities = self._session.scalars(query).all() + event_entities = (self._session.query(EventEntity)).all() # Convert entities to details models and return - return [entity.to_details_model() for entity in entities] + return [ + event_entity.to_details_model(subject) for event_entity in event_entities + ] - def create(self, subject: User, event: Event) -> EventDetails: + def get_events_in_time_range( + self, time_range: TimeRange, subject: User | None = None + ) -> list[EventDetails]: + """ + Get events in the time range + + Args: + subject: The User making the request. + time_range: The period over which to search for events. + + Returns: + list[EventDetails]: list of valid EventDetails models representing the events + """ + event_entities = ( + self._session.query(EventEntity) + .where(EventEntity.time >= time_range.start) + .where(EventEntity.time < time_range.end) + ) + + return [entity.to_details_model(subject) for entity in event_entities] + + def create(self, subject: User, event: DraftEvent) -> EventDetails: """ Creates a event based on the input object and adds it to the table. If the event's ID is unique to the table, a new entry is added. - Parameters: + Args: subject: a valid User model representing the currently logged in User event: a valid Event model representing the event to be added @@ -61,34 +112,46 @@ def create(self, subject: User, event: Event) -> EventDetails: # Ensure that the user has appropriate permissions to create users self._permission.enforce( subject, - "organization.events.manage", + "organization.events.create", f"organization/{event.organization_id}", ) - # Checks if the event already exists in the table - if event.id: - event.id = None - # Otherwise, create new object - event_entity = EventEntity.from_model(event) + event_entity = EventEntity.from_draft_model(event) # Add new object to table and commit changes self._session.add(event_entity) self._session.commit() + # Retrieve the detail model of the event created + event_details = event_entity.to_details_model() + + # Set the user as the organizer of the event + for organizer in event.organizers: + if organizer.id != None: + self.set_event_organizer( + subject=subject, user_id=organizer.id, event=event_details + ) + # Return added object - return event_entity.to_details_model() + # NOTE: Must re-convert the entity to a model again so that the registration + # for the event organizer is automatically populated + return event_entity.to_details_model(subject) - def get_by_id(self, id: int) -> EventDetails: + def get_by_id(self, id: int, subject: User | None = None) -> EventDetails: """ Get the event from an id If none retrieved, a debug description is displayed. - Parameters: + Args: id: a valid int representing a unique event ID + subject: The User making the request. Returns: - Event: Object with corresponding ID + EventDetails: a valid EventDetails model representing the event corresponding to the ID + + Raises: + ResourceNotFoundException when event ID cannot be looked up """ # Query the event with matching id @@ -99,48 +162,42 @@ def get_by_id(self, id: int) -> EventDetails: raise ResourceNotFoundException(f"No event found with matching ID: {id}") # Convert entry to a model and return - return entity.to_details_model() + return entity.to_details_model(subject) - def get_events_by_organization(self, slug: str) -> list[EventDetails]: + def get_events_by_organization( + self, organization: OrganizationDetails, subject: User | None = None + ) -> list[EventDetails]: """ Get all the events hosted by an organization with slug - Parameters: + Args: slug: a valid str representing a unique Organization slug + subject: The User making the request. Returns: list[EventDetail]: a list of valid EventDetails models """ - - # Query the organization with the matching slug + # Query the event with matching organization slug events = ( self._session.query(EventEntity) - .join(OrganizationEntity) - .where(OrganizationEntity.slug == slug) + .filter(EventEntity.organization_id == organization.id) .all() ) # Convert entities to models and return - return [event.to_details_model() for event in events] + return [event.to_details_model(subject) for event in events] def update(self, subject: User, event: Event) -> EventDetails: """ Update the event - Parameters: + Args: event: a valid Event model Returns: EventDetails: a valid EventDetails model representing the updated event object """ - # Ensure that the user has appropriate permissions to update users - self._permission.enforce( - subject, - "organization.events.manage", - f"organization/{event.organization_id}", - ) - # Query the event with matching id event_entity = self._session.get(EventEntity, event.id) @@ -148,44 +205,415 @@ def update(self, subject: User, event: Event) -> EventDetails: if event_entity is None: raise ResourceNotFoundException(f"No event found with matching ID: {id}") + # Ensure that the user has appropriate permissions to update event information + event_details = event_entity.to_details_model(subject) + + # If not organizer, enforce permissions + if not event_details.is_organizer: + self._permission.enforce( + subject, + "organization.events.update", + f"organization/{event.organization_id}", + ) + # Update event object event_entity.name = event.name event_entity.time = event.time event_entity.description = event.description event_entity.location = event.location event_entity.public = event.public + event_entity.can_register = event.can_register + event_entity.registration_limit = event.registration_limit + + # If attempting to edit organizers, enforce registration management permissions + if event.organizers != event_details.organizers: + self._permission.enforce( + subject, + "organization.events.manage_registrations", + f"organization/{event.organization_id}", + ) + # Remove organizers not in new organizers + for organizer in event_details.organizers: + if organizer not in event.organizers: + event_registration_entity = self._session.get( + EventRegistrationEntity, (event.id, organizer.id) + ) + self._session.delete(event_registration_entity) + + # Add organizers not in current organizers + for organizer in event.organizers: + if organizer not in event_details.organizers: + event_registration_entity = self._session.get( + EventRegistrationEntity, (event.id, organizer.id) + ) + + if event_registration_entity is None: + if organizer.id != None: + self.set_event_organizer( + subject, organizer.id, event_details + ) + continue + + event_registration_entity.registration_type = ( + RegistrationType.ORGANIZER + ) # Save changes self._session.commit() # Return updated object - return event_entity.to_details_model() + return event_entity.to_details_model(subject) def delete(self, subject: User, id: int) -> None: """ Delete the event based on the provided ID. If no item exists to delete, a debug description is displayed. - Parameters: + Args: id: an int representing a unique event ID """ # Find object to delete event = self._session.get(EventEntity, id) + # Ensure object exists + if event is None: + raise ResourceNotFoundException(f"No event found with matching ID: {id}") + # Ensure that the user has appropriate permissions to delete users self._permission.enforce( subject, - "organization.events.manage", + "organization.events.delete", f"organization/{event.organization_id}", ) - # Ensure object exists - if event is None: - raise ResourceNotFoundException(f"No event found with matching ID: {id}") - # Delete object and commit self._session.delete(event) # Save changes self._session.commit() + + """Event Registration Service Methods""" + + def get_registration( + self, subject: User, attendee: User, event: EventDetails + ) -> EventMember | None: + """ + Get a registration of an attendee for an Event. + + Args: + subject: User requesting the registration object + attendee: User registered for the event + event: EventDetails of the event seeking registration for + + Returns: + EventMember or None if no registration found + + Raises: + UserPermissionException if subject does not have permission + """ + # Administrative Permission: organization.events.view : organization/{id} + if subject.id != attendee.id: + self._permission.enforce( + subject, + "organization.events.manage_registrations", + f"organization/{event.organization.id}", + ) + + # Query for EventRegistration + event_registration_entity = ( + self._session.query(EventRegistrationEntity) + .where(EventRegistrationEntity.user_id == attendee.id) + .where(EventRegistrationEntity.event_id == event.id) + .one_or_none() + ) + + # Return EventRegistration model or None + if event_registration_entity is not None: + return event_registration_entity.to_flat_model() + else: + return None + + def get_registrations_of_event( + self, subject: User, event: EventDetails + ) -> list[EventMember]: + """ + List the registrations of an event. + + This API endpoint currently requires the subject to be registered as the + organizer of an event or have administrative permission of action + "organization.events.view" for "organization/{organization id}". + + Args: + subject: The authenticated user making the request. + event: The event whose registrations are being queried. + + Returns: + list[EventMember] + + Raises: + UserPermissionException if user is not an event organizer or admin. + """ + if not event.is_organizer: + self._permission.enforce( + subject, + "organization.events.manage_registrations", + f"organization/{event.organization.id}", + ) + + event_registration_entities = ( + self._session.query(EventRegistrationEntity) + .where(EventRegistrationEntity.event_id == event.id) + .all() + ) + + return [entity.to_flat_model() for entity in event_registration_entities] + + def set_event_organizer( + self, subject: User, user_id: int, event: EventDetails + ) -> EventMember: + """ + Set the organizer of an event. + + Args: + subject: User making the registration request + event: The EventDetails being registered for + + Returns: + EventMember + + """ + + # Re-ensure that the user has the correct permissions to run this command + self._permission.enforce( + subject, + "organization.events.manage_registrations", + f"organization/{event.organization_id}", + ) + + # Add new object to table and commit changes + event_registration_entity = EventRegistrationEntity( + user_id=user_id, + event_id=event.id, + registration_type=RegistrationType.ORGANIZER, + ) + self._session.add(event_registration_entity) + self._session.commit() + + # Return registration + return event_registration_entity.to_flat_organizer_model() + + def register( + self, subject: User, attendee: User, event: EventDetails + ) -> EventMember: + """ + Register a user for an event. + + Args: + subject: User making the registration request + attendee: The user being registered for the event + event: The EventDetails being registered for + + Returns: + EventMember + + Raises: + UserPermissionException if subject does not have permission to register user + EventRegistrationException if the event is full + """ + if subject.id != attendee.id and not event.is_organizer: + self._permission.enforce( + subject, + "organization.events.manage_registrations", + f"organization/{event.organization.id}", + ) + + # Get the registration status. + # NOTE: It is preferred to use the service function rather than the list of + # registrations passed in from `event` in the case that registrations are added + # between when `event` was fetched and this function runs. + + # Raise exception if event is full. + if event.registration_count >= event.registration_limit: + raise EventRegistrationException(event.id) + + # Enable idemopotency in returning existing registration, if one exists. + # Permission to manage / read registration is enforced in EventService#get_registration + existing_registration = self.get_registration(subject, attendee, event) + if existing_registration: + return existing_registration + + # Add new object to table and commit changes + event_registration_entity = EventRegistrationEntity( + user_id=attendee.id, + event_id=event.id, + registration_type=RegistrationType.ATTENDEE, + ) + self._session.add(event_registration_entity) + self._session.commit() + + # Return registration + return event_registration_entity.to_flat_model() + + def unregister(self, subject: User, attendee: User, event: EventDetails) -> None: + """ + Delete a user's event registration. + + Args: + subject: User performing the unregister action + attendee: User whose registration is being deleted + event: the event the attendee is unregistering for + + Returns: + None in a successful invocation. Idempotent in the case of not registered. + + Raises: + UserPermissionException when the user is not authorized to manage the registration. + """ + + # Find registration to delete + # Permissions for reading/managing registration are enforced in #get_registration + event_registration = self.get_registration(subject, attendee, event) + + # Ensure object exists and user is not organizer of event + if ( + event_registration is None + or event_registration.registration_type == RegistrationType.ORGANIZER + ): + return + + # Delete object and commit + self._session.delete( + self._session.get( + EventRegistrationEntity, + (event.id, attendee.id), + ) + ) + self._session.commit() + + def get_registrations_of_user( + self, subject: User, user: User, time_range: TimeRange + ) -> Sequence[EventMember]: + """ + Get a user's registrations to events falling within a given time range. + + Args: + subject: The User making the request. + user: The User whose registrations are being requested. + time_range: The period over which to search for event registrations. + + Returns: + Sequence[EventMember] event registrations + + Raises: + UserPermissionException when the user is requesting the registrations + of another user and does not have 'user.event_registrations' permission. + """ + # Feature-specific authorization: User is getting their own registrations + # Administrative Permission: user.event_registrations : user/{user_id} + if subject.id != user.id: + self._permission.enforce( + subject, + "user.event_registrations", + f"user/{user.id}", + ) + + registration_entities = ( + self._session.query(EventRegistrationEntity) + .where(EventRegistrationEntity.user_id == user.id) + .join(EventEntity, EventRegistrationEntity.event_id == EventEntity.id) + .where(EventEntity.time >= time_range.start) + .where(EventEntity.time < time_range.end) + ).all() + + return [entity.to_flat_model() for entity in registration_entities] + + def get_registered_users_of_event( + self, subject: User, event_id: int, pagination_params: PaginationParams + ) -> Paginated[User]: + """ + Get registered users of event in a paginated list. + + Args: + subject: The user performing the action. + event_id: a valid int representing a unique Event + pagination_params: The pagination parameters. + + Returns: + Paginated[User]: The paginated list of users. + + Raises: + PermissionException: If the subject does not have the required permission. + """ + event_entity = self._session.get(EventEntity, event_id) + event = event_entity.to_details_model(subject) + + # Ensure that the user has appropriate permissions to view event information + if not event.is_organizer: + self._permission.enforce( + subject, + "organization.events.manage_registrations", + f"organization/{event.organization_id}", + ) + + # Create an alias for the EventRegistrationEntity to be used in join + EventRegistrationAlias = aliased(EventRegistrationEntity) + + # Statement below corresponds to the following SQL Query (when executed) + # Returns all UserEntity objects for EventRegistrations that match the event_id + # SELECT UserEntity.* + # FROM UserEntity JOIN EventRegistrationEntity ON EventRegistrationEntity.user_id == UserEntity.id + # WHERE EventRegistrationEntity.event_id = :event_id + statement = ( + select(UserEntity) + .join( + EventRegistrationAlias, EventRegistrationAlias.user_id == UserEntity.id + ) + .where(EventRegistrationAlias.event_id == event_id) + ) + + # Statement to determine number of rows in query result + length_statement = ( + select(func.count()) + .select_from(UserEntity) + .join( + EventRegistrationAlias, EventRegistrationAlias.user_id == UserEntity.id + ) + .where(EventRegistrationAlias.event_id == event_id) + ) + + # Filter results by query + if pagination_params.filter != "": + query = pagination_params.filter + criteria = or_( + UserEntity.first_name.ilike(f"%{query}%"), + UserEntity.last_name.ilike(f"%{query}%"), + UserEntity.onyen.ilike(f"%{query}%"), + ) + + statement = statement.where(criteria) + length_statement = length_statement.where(criteria) + + # Calculate where to begin retrieving rows and how many to retrieve + offset = pagination_params.page * pagination_params.page_size + limit = pagination_params.page_size + + # Order results by order by attribute + if pagination_params.order_by != "": + statement = statement.order_by( + getattr(UserEntity, pagination_params.order_by) + ) + + # Retrieve limited items + statement = statement.offset(offset).limit(limit) + + # Execute statement and retrieve entities + length = self._session.execute(length_statement).scalar() + entities = self._session.execute(statement).scalars() + + # Convert `UserEntity`s to model and return page + return Paginated( + items=[entity.to_model() for entity in entities], + length=length, + params=pagination_params, + ) diff --git a/backend/services/exceptions.py b/backend/services/exceptions.py index 3509e9db7..a3c4f0745 100644 --- a/backend/services/exceptions.py +++ b/backend/services/exceptions.py @@ -17,3 +17,17 @@ class UserPermissionException(Exception): def __init__(self, action: str, resource: str): super().__init__(f"Not authorized to perform `{action}` on `{resource}`") + + +class OrganizationNotFoundException(Exception): + """OrganizationNotFoundException is raised when trying to access an organization that does not exist.""" + + def __init__(self, id: str): + super().__init__(f"No organization found matching slug/id: {id}") + + +class EventRegistrationException(Exception): + """EventRegistrationException is raised when a user attempts to register and cannot (i.e., when the event is full).""" + + def __init__(self, event_id: int): + super().__init__(f"Unable to register user for the event with id: {event_id}") diff --git a/backend/services/user.py b/backend/services/user.py index 69de06e4c..e190fbb9d 100644 --- a/backend/services/user.py +++ b/backend/services/user.py @@ -9,6 +9,7 @@ from ..database import db_session from ..models import User, UserDetails, Paginated, PaginationParams from ..entities import UserEntity +from .exceptions import ResourceNotFoundException from .permission import PermissionService __authors__ = ["Kris Jordan"] @@ -49,6 +50,24 @@ def get(self, pid: int) -> UserDetails | None: user_details = UserDetails(**user_fields) return user_details + def get_by_id(self, id: int) -> User: + """Get a User by their id. + + Args: + id: The ID of the user. + + Returns: + User + + Raises: + ResourceNotFoundException if the User ID is not found + """ + user_entity = self._session.get(UserEntity, id) + if user_entity is None: + raise ResourceNotFoundException(f"User with {id} not found") + + return user_entity.to_model() + def search(self, _subject: User, query: str) -> list[User]: """Search for users by their name, onyen, email. diff --git a/backend/test/services/event/event_demo_data.py b/backend/test/services/event/event_demo_data.py index 90682e91f..eb16812f6 100644 --- a/backend/test/services/event/event_demo_data.py +++ b/backend/test/services/event/event_demo_data.py @@ -51,6 +51,8 @@ def date_maker(days_in_future: int, hour: int, minutes: int) -> datetime.datetim location="Sitterson Hall", description="Mark your calendars for the 2023 Carolina Data Challenge (CDC)! CDC is UNC's weekend-long datathon that brings together hundreds of participants from across campus, numerous corporate sponsors, tons of free food as well as merch, and hundreds of dollars of prizes!", public=True, + registration_limit=50, + can_register=True, organization_id=cads.id, ) @@ -60,6 +62,8 @@ def date_maker(days_in_future: int, hour: int, minutes: int) -> datetime.datetim location="SN 014", description="This is a sample description.", public=True, + registration_limit=50, + can_register=True, organization_id=cssg.id, ) @@ -69,6 +73,8 @@ def date_maker(days_in_future: int, hour: int, minutes: int) -> datetime.datetim location="Fetzer Gym", description="HackNC is a weekend for students of all skill levels to broaden their talents. Your challenge is to make an awesome project in just 24 hours. You will have access to hands-on tech workshops, sponsor networking, as well as exciting talks about the awesome things happening right now with computer science and technology - not to mention all of the free food, shirts, stickers, and swag! We are the largest hackathon in the southeastern United States.", public=True, + registration_limit=50, + can_register=True, organization_id=hacknc.id, ) @@ -78,6 +84,8 @@ def date_maker(days_in_future: int, hour: int, minutes: int) -> datetime.datetim location="FB 009", description="If you are interested in web scraping, come out to learn!", public=True, + registration_limit=50, + can_register=True, organization_id=cads.id, ) diff --git a/backend/test/services/event/event_test.py b/backend/test/services/event/event_test.py index d69d605f2..00fd38848 100644 --- a/backend/test/services/event/event_test.py +++ b/backend/test/services/event/event_test.py @@ -3,38 +3,85 @@ # PyTest import pytest from unittest.mock import create_autospec +from backend.models.pagination import PaginationParams +from backend.models.registration_type import RegistrationType from backend.services.exceptions import ( + EventRegistrationException, UserPermissionException, ResourceNotFoundException, ) +from backend.services.organization import OrganizationService + +# Time helpers +from ....models.coworking.time_range import TimeRange +from ..coworking.time import * # Tested Dependencies from ....models import Event, EventDetails from ....services import EventService # Injected Service Fixtures -from ..fixtures import event_svc_integration +from ..fixtures import ( + user_svc_integration, + event_svc_integration, + organization_svc_integration, +) # Explicitly import Data Fixture to load entities in database from ..core_data import setup_insert_data_fixture # Data Models for Fake Data Inserted in Setup -from .event_test_data import events, event_one, to_add, updated_event -from ..user_data import root, user +from .event_test_data import ( + events, + event_one, + event_two, + to_add, + updated_event_one, + updated_event_one_organizers, + updated_event_two, + updated_event_three, + updated_event_three_remove_organizers, + invalid_event, + event_three, +) +from ..user_data import root, ambassador, user # Test Functions def test_get_all(event_svc_integration: EventService): + """Test that all events can be retrieved.""" + fetched_events = event_svc_integration.all(ambassador) + + assert fetched_events is not None + assert len(fetched_events) == len(events) + assert isinstance(fetched_events[0], EventDetails) + + assert fetched_events[0].is_attendee == True + assert fetched_events[1].is_attendee == False + assert fetched_events[2].is_attendee == True + + +def test_get_all_unauthenticated(event_svc_integration: EventService): """Test that all events can be retrieved.""" fetched_events = event_svc_integration.all() + assert fetched_events is not None assert len(fetched_events) == len(events) assert isinstance(fetched_events[0], EventDetails) def test_get_by_id(event_svc_integration: EventService): + """Test that events can be retrieved based on their ID.""" + fetched_event = event_svc_integration.get_by_id(1, ambassador) + assert fetched_event is not None + assert isinstance(fetched_event, Event) + assert fetched_event.id == event_one.id + assert fetched_event.is_attendee == True + + +def test_get_by_id_unauthenticated(event_svc_integration: EventService): """Test that events can be retrieved based on their ID.""" fetched_event = event_svc_integration.get_by_id(1) assert fetched_event is not None @@ -52,8 +99,8 @@ def test_create_enforces_permission(event_svc_integration: EventService): # Test permissions with root user (admin permission) event_svc_integration.create(root, to_add) - event_svc_integration._permission.enforce.assert_called_with( - root, "organization.events.manage", f"organization/{to_add.organization_id}" + event_svc_integration._permission.enforce.assert_any_call( + root, "organization.events.create", f"organization/{to_add.organization_id}" ) @@ -63,6 +110,13 @@ def test_create_event_as_root(event_svc_integration: EventService): assert created_event is not None assert created_event.id is not None + assert len(created_event.organizers) == 1 + assert created_event.organizers[0].id == root.id + assert created_event.is_organizer == True + + assert len(created_event.attendees) == 0 + assert created_event.is_attendee == False + def test_create_event_as_user(event_svc_integration: EventService): """Test that any user is *unable* to create new events.""" @@ -71,27 +125,110 @@ def test_create_event_as_user(event_svc_integration: EventService): pytest.fail() # Fail test if no error was thrown above -def test_get_events_by_organization(event_svc_integration: EventService): +def test_get_events_by_organization( + event_svc_integration: EventService, + organization_svc_integration: OrganizationService, +): """Test that list of events can be retrieved based on specified organization.""" - fetched_events = event_svc_integration.get_events_by_organization("cssg") + organization = organization_svc_integration.get_by_slug("cssg") + fetched_events = event_svc_integration.get_events_by_organization( + organization, ambassador + ) assert fetched_events is not None - assert len(fetched_events) == 2 + assert len(fetched_events) == 3 + assert fetched_events[0].is_attendee == True + assert fetched_events[1].is_attendee == False + assert fetched_events[2].is_attendee == True + + +def test_get_events_by_organization_organizer( + event_svc_integration: EventService, + organization_svc_integration: OrganizationService, +): + """Test that list of events can be retrieved based on specified organization.""" + organization = organization_svc_integration.get_by_slug("cssg") + fetched_events = event_svc_integration.get_events_by_organization( + organization, user + ) + assert fetched_events is not None + assert len(fetched_events) == 3 + assert fetched_events[0].is_organizer == True + assert fetched_events[1].is_organizer == False + assert fetched_events[2].is_organizer == False + + +def test_get_events_by_organization_unauthenticated( + event_svc_integration: EventService, + organization_svc_integration: OrganizationService, +): + """Test that list of events can be retrieved based on specified organization.""" + organization = organization_svc_integration.get_by_slug("cssg") + fetched_events = event_svc_integration.get_events_by_organization(organization) + assert fetched_events is not None + assert len(fetched_events) == 3 def test_update_event_as_root( event_svc_integration: EventService, ): - """Test that the root user is able to create new events. - Note: Test data's website field is updated + """Test that the root user is able to update new events. + Note: Test data's name and location field is updated + """ + event_svc_integration.update(root, updated_event_one) + assert event_svc_integration.get_by_id(1).name == "Carolina Data Challenge" + assert event_svc_integration.get_by_id(1).location == "Fetzer Gym" + + +def test_update_event_organizers_as_root( + event_svc_integration: EventService, +): + """Test that the root user is able to update new events. + Note: Test data's name and location field is updated + """ + event_svc_integration.update(root, updated_event_three) + updated_organizers = event_svc_integration.get_by_id(3).organizers + assert updated_organizers[0].id == ambassador.id + assert updated_organizers[1].id == user.id + assert updated_organizers[2].id == root.id + + event_svc_integration.update(root, updated_event_three_remove_organizers) + updated_organizers = event_svc_integration.get_by_id(3).organizers + assert len(updated_organizers) == 1 + assert updated_organizers[0].id == user.id + + event_svc_integration.update(root, updated_event_three) + updated_organizers = event_svc_integration.get_by_id(3).organizers + assert updated_organizers[0].id == user.id + assert updated_organizers[1].id == ambassador.id + + +def test_update_event_organizers_as_user( + event_svc_integration: EventService, +): + """Test that the organizer user cannot update organizers. + Note: Test data's name and location field is updated """ - event_svc_integration.update(root, updated_event) + with pytest.raises(UserPermissionException): + event_svc_integration.update(user, updated_event_three) + + +def test_update_event_as_organizer(event_svc_integration: EventService): + """Test that an organizer user is able to update events""" + event_svc_integration.update(user, updated_event_one) + assert event_svc_integration.get_by_id(1).name == "Carolina Data Challenge" assert event_svc_integration.get_by_id(1).location == "Fetzer Gym" def test_update_event_as_user(event_svc_integration: EventService): """Test that any user is *unable* to create new events.""" with pytest.raises(UserPermissionException): - event_svc_integration.update(user, updated_event) + event_svc_integration.update(user, updated_event_two) + + +def test_update_on_invalid_event(event_svc_integration: EventService): + """Test that attempting to update a nonexistent event raises an exception.""" + with pytest.raises(ResourceNotFoundException): + event_svc_integration.update(user, invalid_event) def test_delete_enforces_permission(event_svc_integration: EventService): @@ -105,7 +242,7 @@ def test_delete_enforces_permission(event_svc_integration: EventService): # Test permissions with root user (admin permission) event_svc_integration.delete(root, 1) event_svc_integration._permission.enforce.assert_called_with( - root, "organization.events.manage", f"organization/{event_one.organization_id}" + root, "organization.events.delete", f"organization/{event_one.organization_id}" ) @@ -120,3 +257,316 @@ def test_delete_event_as_user(event_svc_integration: EventService): """Test that any user is *unable* to delete events.""" with pytest.raises(UserPermissionException): event_svc_integration.delete(user, 1) + + +def test_delete_on_invalid_event(event_svc_integration: EventService): + """Test that attempting to delete a nonexistent event raises an exception.""" + with pytest.raises(ResourceNotFoundException): + event_svc_integration.delete(user, invalid_event.id) + + +def test_register_for_event_as_user(event_svc_integration: EventService): + """Test that a user is able to register for an event.""" + event_details = event_svc_integration.get_by_id(event_one.id, root) # type: ignore + created_registration = event_svc_integration.register(root, root, event_details) # type: ignore + assert created_registration is not None + assert created_registration.registration_type == RegistrationType.ATTENDEE + + +def test_register_for_event_as_user_twice(event_svc_integration: EventService): + """Test that a user's second registration for an event is idempotent.""" + event_details = event_svc_integration.get_by_id(event_one.id, root) # type: ignore + created_registration_1 = event_svc_integration.register(user, user, event_details) # type: ignore + assert created_registration_1 is not None + created_registration_2 = event_svc_integration.register(user, user, event_details) # type: ignore + assert created_registration_2 is not None + assert created_registration_1 == created_registration_2 + + +def test_register_for_event_enforces_permission(event_svc_integration: EventService): + event_svc_integration._permission = create_autospec( + event_svc_integration._permission + ) + event_details = event_svc_integration.get_by_id(event_one.id, root) # type: ignore + event_svc_integration.register(root, user, event_details) # type: ignore + event_svc_integration._permission.enforce.assert_any_call( + root, + "organization.events.manage_registrations", + f"organization/{event_details.organization.id}", + ) + + +def test_get_registration(event_svc_integration: EventService): + event_details = event_svc_integration.get_by_id(event_one.id, ambassador) # type: ignore + event_registration = event_svc_integration.get_registration( + ambassador, ambassador, event_details + ) + assert event_registration is not None + + +def test_get_registration_that_does_not_exist(event_svc_integration: EventService): + event_details = event_svc_integration.get_by_id(event_one.id, root) # type: ignore + event_registration = event_svc_integration.get_registration( + root, root, event_details + ) + assert event_registration is None + + +def test_get_registrations_of_event_as_organizer(event_svc_integration: EventService): + event_details = event_svc_integration.get_by_id(event_one.id, user) # type: ignore + event_registrations = event_svc_integration.get_registrations_of_event( + user, event_details + ) + assert len(event_registrations) == 2 + + +def test_get_registrations_of_event_non_organizer(event_svc_integration: EventService): + event_details = event_svc_integration.get_by_id(event_one.id, ambassador) # type: ignore + with pytest.raises(UserPermissionException): + event_svc_integration.get_registrations_of_event(ambassador, event_details) + + +def test_get_registrations_enforces_admin_auth( + event_svc_integration: EventService, +): + """Test that root is able to delete any registrations.""" + # Setup mock to test permission enforcement on the PermissionService. + event_svc_integration._permission = create_autospec( + event_svc_integration._permission + ) + + # Ensure delete occurs + event_details = event_svc_integration.get_by_id(event_one.id, ambassador) # type: ignore + event_svc_integration.get_registrations_of_event(ambassador, event_details) + + # Ensure that the correct permission check is run + event_svc_integration._permission.enforce.assert_called_with( + ambassador, + "organization.events.manage_registrations", + f"organization/{event_one.organization_id}", + ) + + +def test_unregister_for_event_as_registerer( + event_svc_integration: EventService, +): + """Test that a user is able to unregister for an event.""" + event_details = event_svc_integration.get_by_id(event_one.id) # type: ignore + assert ( + event_svc_integration.get_registration(ambassador, ambassador, event_details) + is not None + ) + event_svc_integration.unregister(ambassador, ambassador, event_details) + assert ( + event_svc_integration.get_registration(ambassador, ambassador, event_details) + is None + ) + + +def test_unregister_for_event_as_registerer_is_idempotent( + event_svc_integration: EventService, +): + """Test that a user is able to unregister for an event.""" + event_details = event_svc_integration.get_by_id(event_one.id) # type: ignore + event_svc_integration.unregister(ambassador, ambassador, event_details) + event_svc_integration.unregister(ambassador, ambassador, event_details) + assert ( + event_svc_integration.get_registration(ambassador, ambassador, event_details) + is None + ) + + +def test_unregister_for_event_as_wrong_user( + event_svc_integration: EventService, +): + """Test that any user is *unable* to delete a registration that is not for them.""" + event_details = event_svc_integration.get_by_id(event_one.id) # type: ignore + with pytest.raises(UserPermissionException): + event_svc_integration.unregister(user, ambassador, event_details) + + +def test_unregister_for_event_enforces_admin_auth( + event_svc_integration: EventService, +): + """Test that root is able to delete any registrations.""" + # Setup mock to test permission enforcement on the PermissionService. + event_svc_integration._permission = create_autospec( + event_svc_integration._permission + ) + + # Ensure delete occurs + event_details = event_svc_integration.get_by_id(event_one.id, root) # type: ignore + event_svc_integration.unregister(root, ambassador, event_details) + + # Ensure that the correct permission check is run + event_svc_integration._permission.enforce.assert_called_with( + root, + "organization.events.manage_registrations", + f"organization/{event_one.organization_id}", + ) + + +def test_get_registrations_of_user( + event_svc_integration: EventService, time: dict[str, datetime] +): + """Test that a user with a registration is able to query for it.""" + time_range = TimeRange(start=event_one.time - ONE_DAY, end=event_one.time + ONE_DAY) + registrations = event_svc_integration.get_registrations_of_user( + ambassador, ambassador, time_range + ) + assert len(registrations) == 1 + + +def test_get_registrations_of_user_outside_time_range( + event_svc_integration: EventService, time: dict[str, datetime] +): + """Test that a user with a registration is able to query for it.""" + # Test range after event + time_range = TimeRange( + start=event_one.time + 2 * ONE_DAY, end=event_one.time + 3 * ONE_DAY + ) + registrations = event_svc_integration.get_registrations_of_user( + ambassador, ambassador, time_range + ) + assert len(registrations) == 0 + + # Test range before event + time_range = TimeRange( + start=event_one.time - ONE_DAY * 2, end=event_one.time - ONE_DAY + ) + registrations = event_svc_integration.get_registrations_of_user( + ambassador, ambassador, time_range + ) + assert len(registrations) == 0 + + +def test_get_registrations_of_user_without_reservations( + event_svc_integration: EventService, time: dict[str, datetime] +): + """Test that a user without any registrations is able to query for it.""" + time_range = TimeRange(start=event_one.time - ONE_DAY, end=event_one.time + ONE_DAY) + registrations = event_svc_integration.get_registrations_of_user( + root, root, time_range + ) + assert len(registrations) == 0 + + +def test_get_registrations_of_user_admin_authorization( + event_svc_integration: EventService, +): + """Test that administrative permissions are enforced.""" + # Setup mock to test permission enforcement on the PermissionService. + event_svc_integration._permission = create_autospec( + event_svc_integration._permission + ) + + # Ensure delete occurs + time_range = TimeRange(start=event_one.time - ONE_DAY, end=event_one.time + ONE_DAY) + event_svc_integration.get_registrations_of_user(root, ambassador, time_range) + + # Ensure that the correct permission check is run + event_svc_integration._permission.enforce.assert_called_with( + root, "user.event_registrations", f"user/{ambassador.id}" + ) + + +def test_register_to_full_event( + event_svc_integration: EventService, +): + """Tests that a user cannot register for an event that is full.""" + event_details = event_svc_integration.get_by_id(event_three.id) # type: ignore + + with pytest.raises(EventRegistrationException): + event_svc_integration.register(user, user, event_details) + + +def test_get_registered_users_of_event(event_svc_integration: EventService): + """Tests querying for registered users of events as a paginated list""" + pagination_params = PaginationParams( + page=0, page_size=10, order_by="first_name", filter="" + ) + page = event_svc_integration.get_registered_users_of_event( + root, event_one.id, pagination_params + ) + + assert len(page.items) == 2 + assert page.length == 2 + assert page.items[0].id == ambassador.id + assert page.items[1].id == user.id + + +def test_organizer_get_registered_users_of_event(event_svc_integration: EventService): + """Tests that organizers for an event can retrieve registered users""" + # Setup to test permission enforcement on the PermissionService. + pagination_params = PaginationParams( + page=0, page_size=10, order_by="first_name", filter="" + ) + page = event_svc_integration.get_registered_users_of_event( + user, event_one.id, pagination_params + ) + + assert len(page.items) == 2 + assert page.length == 2 + assert page.items[0].id == ambassador.id + assert page.items[1].id == user.id + + +def test_organizer_get_registered_users_of_event_filtered( + event_svc_integration: EventService, +): + """Tests that organizers for an event can retrieve registered users""" + # Setup to test permission enforcement on the PermissionService. + pagination_params = PaginationParams( + page=0, page_size=10, order_by="first_name", filter="Amy" + ) + page = event_svc_integration.get_registered_users_of_event( + user, event_one.id, pagination_params + ) + + assert len(page.items) == 1 + assert page.length == 1 + assert page.items[0].id == ambassador.id + + +def test_get_registered_users_of_event_permissions(event_svc_integration: EventService): + """Tests that the service method for retrieving registered users of an event enforces permissions""" + # Setup to test permission enforcement on the PermissionService. + event_svc_integration._permission = create_autospec( + event_svc_integration._permission + ) + pagination_params = PaginationParams( + page=0, page_size=10, order_by="first_name", filter="" + ) + + # Test permissions with root user (admin permission) + event_svc_integration.get_registered_users_of_event( + root, event_one.id, pagination_params + ) + event_svc_integration._permission.enforce.assert_called_with( + root, + "organization.events.manage_registrations", + f"organization/{event_one.organization_id}", + ) + + +def test_get_registered_users_of_event_without_permissions( + event_svc_integration: EventService, +): + """Tests that the service method for retrieving registered users of an event enforces permissions""" + # Setup to test permission enforcement on the PermissionService. + event_svc_integration._permission = create_autospec( + event_svc_integration._permission + ) + pagination_params = PaginationParams( + page=0, page_size=10, order_by="first_name", filter="" + ) + + # Test permissions with root user (admin permission) + event_svc_integration.get_registered_users_of_event( + user, event_two.id, pagination_params + ) + event_svc_integration._permission.enforce.assert_called_with( + user, + "organization.events.manage_registrations", + f"organization/{event_one.organization_id}", + ) diff --git a/backend/test/services/event/event_test_data.py b/backend/test/services/event/event_test_data.py index 0696313ed..8d78f2196 100644 --- a/backend/test/services/event/event_test_data.py +++ b/backend/test/services/event/event_test_data.py @@ -2,11 +2,16 @@ import pytest from sqlalchemy.orm import Session -from ....models.event import Event + +from backend.models.event_member import EventMember, EventOrganizer +from ....models.event import DraftEvent, Event +from ....models.event_registration import EventRegistration, NewEventRegistration +from ....models.registration_type import RegistrationType from ....entities.event_entity import EventEntity +from ....entities.event_registration_entity import EventRegistrationEntity from .event_demo_data import date_maker from ..organization.organization_test_data import cads, cssg - +from ..user_data import root, ambassador, user from ..reset_table_id_seq import reset_table_id_seq __authors__ = ["Ajay Gandecha"] @@ -23,6 +28,8 @@ location="Sitterson Hall Lower Lobby", description="Mark your calendars for the 2023 Carolina Data Challenge (CDC)! CDC is UNC's weekend-long datathon that brings together hundreds of participants from across campus, numerous corporate sponsors, tons of free food as well as merch, and hundreds of dollars of prizes!", public=True, + registration_limit=50, + can_register=True, organization_id=cssg.id | 0, ) @@ -33,30 +40,202 @@ location="SN 014", description="This is a sample description.", public=True, + registration_limit=50, + can_register=True, + organization_id=cssg.id | 0, +) + +event_three = Event( + id=3, + name="Super Exclusive Meeting", + time=date_maker(days_in_future=2, hour=19, minutes=0), + location="SN 014", + description="This is a sample description.", + public=True, + registration_limit=1, + can_register=True, organization_id=cssg.id | 0, ) -events = [event_one, event_two] +events = [event_one, event_two, event_three] -to_add = Event( +to_add = DraftEvent( name="Carolina Data Challenge", time=date_maker(days_in_future=2, hour=20, minutes=0), location="SN011", description="This is a sample description.", public=True, + registration_limit=50, + can_register=True, organization_id=cads.id | 0, + organizers=[ + EventOrganizer( + id=root.id, + first_name=root.first_name, + last_name=root.last_name, + pronouns=root.pronouns, + email=root.email, + registration_type=RegistrationType.ORGANIZER, + ) + ], ) -updated_event = Event( +invalid_event = Event( + id=4, + name="Frontend Debugging Workshop", + time=date_maker(days_in_future=1, hour=10, minutes=0), + location="SN156", + description="This is a sample description.", + public=True, + registration_limit=50, + can_register=True, + organization_id=cssg.id | 0, +) + +updated_event_one = Event( id=1, name="Carolina Data Challenge", time=date_maker(days_in_future=1, hour=10, minutes=0), location="Fetzer Gym", description="Mark your calendars for the 2023 Carolina Data Challenge (CDC)! CDC is UNC's weekend-long datathon that brings together hundreds of participants from across campus, numerous corporate sponsors, tons of free food as well as merch, and hundreds of dollars of prizes!", public=True, + registration_limit=50, + can_register=True, + organization_id=cssg.id | 0, + organizers=[ + EventOrganizer( + id=user.id, + first_name=user.first_name, + last_name=user.last_name, + pronouns=user.pronouns, + email=user.email, + registration_type=RegistrationType.ORGANIZER, + ), + ], +) + +updated_event_one_organizers = Event( + id=1, + name="Carolina Data Challenge", + time=date_maker(days_in_future=1, hour=10, minutes=0), + location="Fetzer Gym", + description="Mark your calendars for the 2023 Carolina Data Challenge (CDC)! CDC is UNC's weekend-long datathon that brings together hundreds of participants from across campus, numerous corporate sponsors, tons of free food as well as merch, and hundreds of dollars of prizes!", + public=True, + registration_limit=50, + can_register=True, + organization_id=cssg.id | 0, + organizers=[ + EventOrganizer( + id=user.id, + first_name=user.first_name, + last_name=user.last_name, + pronouns=user.pronouns, + email=user.email, + registration_type=RegistrationType.ORGANIZER, + ), + EventOrganizer( + id=ambassador.id, + first_name=ambassador.first_name, + last_name=ambassador.last_name, + pronouns=ambassador.pronouns, + email=ambassador.email, + registration_type=RegistrationType.ORGANIZER, + ), + ], +) + +updated_event_two = Event( + id=2, + name="CS+SG Workshop", + time=date_maker(days_in_future=2, hour=19, minutes=0), + location="SN 014", + description="Come join us for a new workshop!", + public=True, + registration_limit=50, + can_register=True, + organization_id=cssg.id | 0, +) + +updated_event_three = Event( + id=3, + name="Super Exclusive Meeting", + time=date_maker(days_in_future=2, hour=19, minutes=0), + location="SN 014", + description="This is a sample description.", + public=True, + registration_limit=1, + can_register=True, organization_id=cssg.id | 0, + organizers=[ + EventOrganizer( + id=user.id, + first_name=user.first_name, + last_name=user.last_name, + pronouns=user.pronouns, + email=user.email, + registration_type=RegistrationType.ORGANIZER, + ), + EventOrganizer( + id=ambassador.id, + first_name=ambassador.first_name, + last_name=ambassador.last_name, + pronouns=ambassador.pronouns, + email=ambassador.email, + registration_type=RegistrationType.ORGANIZER, + ), + EventOrganizer( + id=root.id, + first_name=root.first_name, + last_name=root.last_name, + pronouns=root.pronouns, + email=root.email, + registration_type=RegistrationType.ORGANIZER, + ), + ], ) +updated_event_three_remove_organizers = Event( + id=3, + name="Super Exclusive Meeting", + time=date_maker(days_in_future=2, hour=19, minutes=0), + location="SN 014", + description="This is a sample description.", + public=True, + registration_limit=1, + can_register=True, + organization_id=cssg.id | 0, + organizers=[ + EventOrganizer( + id=user.id, + first_name=user.first_name, + last_name=user.last_name, + pronouns=user.pronouns, + email=user.email, + registration_type=RegistrationType.ORGANIZER, + ), + ], +) + + +registration = NewEventRegistration( + event_id=event_one.id | 0, + user_id=ambassador.id | 0, + registration_type=RegistrationType.ATTENDEE, +) + +organizer_registration = NewEventRegistration( + event_id=event_one.id | 0, + user_id=user.id | 0, + registration_type=RegistrationType.ORGANIZER, +) + +registration_for_event_three = NewEventRegistration( + event_id=event_three.id | 0, + user_id=ambassador.id | 0, + registration_type=RegistrationType.ATTENDEE, +) +registrations = [registration, organizer_registration, registration_for_event_three] + # Data Functions @@ -72,6 +251,10 @@ def insert_fake_data(session: Session): session.add(event_entity) entities.append(event_entity) + for registration in registrations: + registration_entity = EventRegistrationEntity.from_new_model(registration) + session.add(registration_entity) + # Reset table IDs to prevent ID conflicts reset_table_id_seq(session, EventEntity, EventEntity.id, len(events) + 1) diff --git a/backend/test/services/fixtures.py b/backend/test/services/fixtures.py index 0ce77c66f..78e8ae404 100644 --- a/backend/test/services/fixtures.py +++ b/backend/test/services/fixtures.py @@ -52,7 +52,7 @@ def organization_svc_integration(session: Session): @pytest.fixture() -def event_svc_integration(session: Session): +def event_svc_integration(session: Session, user_svc_integration: UserService): """This fixture is used to test the EventService class with a real PermissionService.""" return EventService(session, PermissionService(session)) diff --git a/backend/test/services/user_test.py b/backend/test/services/user_test.py index 4b4f0fa92..4f10ff145 100644 --- a/backend/test/services/user_test.py +++ b/backend/test/services/user_test.py @@ -1,9 +1,12 @@ """Tests for the UserService class.""" +import pytest + # Tested Dependencies from ...models.user import User, NewUser from ...models.pagination import PaginationParams from ...services import UserService, PermissionService +from ...services.exceptions import ResourceNotFoundException # Data Setup and Injected Service Fixtures from .core_data import setup_insert_data_fixture @@ -41,6 +44,20 @@ def test_get_nonexistent(user_svc_integration: UserService): assert user_svc_integration.get(423) is None +def test_get_by_id(user_svc_integration: UserService): + """Test that a user can be retrieved by their ID""" + user = user_svc_integration.get_by_id(ambassador.id) # type: ignore + assert user is not None + assert user.id == ambassador.id + assert user.pid == ambassador.pid + + +def test_get_by_id_nonexistent(user_svc_integration: UserService): + """Test that a user id that does not exist returns None""" + with pytest.raises(ResourceNotFoundException): + user_svc_integration.get_by_id(423) + + def test_search_by_first_name(user_svc: UserService): """Test that a user can be retrieved by Searching for their first name.""" users = user_svc.search(ambassador, "amy") diff --git a/frontend/src/app/admin/organization/list/admin-organization-list.component.ts b/frontend/src/app/admin/organization/list/admin-organization-list.component.ts index b342e785a..cfa1f8f02 100644 --- a/frontend/src/app/admin/organization/list/admin-organization-list.component.ts +++ b/frontend/src/app/admin/organization/list/admin-organization-list.component.ts @@ -56,7 +56,8 @@ export class AdminOrganizationListComponent { deleteOrganization(organization: Organization): void { let confirmDelete = this.snackBar.open( 'Are you sure you want to delete this organization?', - 'Delete' + 'Delete', + { duration: 15000 } ); confirmDelete.onAction().subscribe(() => { this.adminOrganizationService diff --git a/frontend/src/app/event/event-details/event-details.component.css b/frontend/src/app/event/event-details/event-details.component.css index 250ad7ddc..403f6ce9e 100644 --- a/frontend/src/app/event/event-details/event-details.component.css +++ b/frontend/src/app/event/event-details/event-details.component.css @@ -1,9 +1,24 @@ .container { - padding: 24px; + padding: 24px; } -.grid { - display: grid; - row-gap: 15px; - column-gap: 24px; -} \ No newline at end of file +.events-grid { + display: grid; + grid-template-columns: 1fr 1fr; + margin-top: 4px; + row-gap: 15px; + column-gap: 24px; + width: inherit; +} + +@media only screen and (max-width: 1100px) { + .events-grid { + grid-template-columns: 1fr; + } +} + +@media only screen and (max-width: 1100px) { + .events-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/app/event/event-details/event-details.component.html b/frontend/src/app/event/event-details/event-details.component.html index 1da8cc5f8..5d7cf7c4f 100644 --- a/frontend/src/app/event/event-details/event-details.component.html +++ b/frontend/src/app/event/event-details/event-details.component.html @@ -1,5 +1,9 @@
-
- +
+ + +
diff --git a/frontend/src/app/event/event-details/event-details.component.ts b/frontend/src/app/event/event-details/event-details.component.ts index 2801c094a..26fa77e44 100644 --- a/frontend/src/app/event/event-details/event-details.component.ts +++ b/frontend/src/app/event/event-details/event-details.component.ts @@ -7,7 +7,7 @@ * @license MIT */ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { profileResolver } from 'src/app/profile/profile.resolver'; import { eventDetailResolver } from '../event.resolver'; import { Profile } from 'src/app/profile/profile.service'; @@ -17,6 +17,8 @@ import { ResolveFn } from '@angular/router'; import { Event } from '../event.model'; +import { Observable, of } from 'rxjs'; +import { PermissionService } from 'src/app/permission.service'; /** Injects the event's name to adjust the title. */ let titleResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { @@ -34,7 +36,10 @@ export class EventDetailsComponent { path: ':id', title: 'Event Details', component: EventDetailsComponent, - resolve: { profile: profileResolver, event: eventDetailResolver }, + resolve: { + profile: profileResolver, + event: eventDetailResolver + }, children: [ { path: '', title: titleResolver, component: EventDetailsComponent } ] @@ -45,11 +50,24 @@ export class EventDetailsComponent { /** Store the currently-logged-in user's profile. */ public profile: Profile; + public adminPermission$: Observable; - constructor(private route: ActivatedRoute) { + constructor( + private route: ActivatedRoute, + private permission: PermissionService + ) { /** Initialize data from resolvers. */ - const data = this.route.snapshot.data as { profile: Profile; event: Event }; + const data = this.route.snapshot.data as { + profile: Profile; + event: Event; + }; this.profile = data.profile; this.event = data.event; + + // Admin Permission if has the actual permission or is event organizer + this.adminPermission$ = this.permission.check( + 'organization.events.view', + `organization/${this.event.organization!.id}` + ); } } diff --git a/frontend/src/app/event/event-editor/event-editor.component.html b/frontend/src/app/event/event-editor/event-editor.component.html index 1286f9aac..4b7874ef7 100644 --- a/frontend/src/app/event/event-editor/event-editor.component.html +++ b/frontend/src/app/event/event-editor/event-editor.component.html @@ -1,12 +1,19 @@ +
+ *ngIf=" + (adminPermission$ | async) || this.event.is_organizer; + else unauthenticated + "> + Create Event Update Event + + Event Name @@ -17,6 +24,8 @@ name="name" required /> + + + + Location + + Description - + name="description"> + + + + + Registration Limit + + + + + + Organizers + + + + {{ organizer.first_name + ' ' + organizer.last_name }} + + + + + + {{ option.first_name }} {{ option.last_name }} + + + + +
- +
diff --git a/frontend/src/app/event/event-page/event-page.component.ts b/frontend/src/app/event/event-page/event-page.component.ts index b5d9f0d80..3a99ce7df 100644 --- a/frontend/src/app/event/event-page/event-page.component.ts +++ b/frontend/src/app/event/event-page/event-page.component.ts @@ -70,7 +70,7 @@ export class EventPageComponent implements OnInit { // Initialize the initially selected event if (data.events.length > 0) { - this.selectedEvent = eventFilterPipe.transform(data.events, "")[0]; + this.selectedEvent = eventFilterPipe.transform(data.events, '')[0]; } } diff --git a/frontend/src/app/event/event.model.ts b/frontend/src/app/event/event.model.ts index 316fce10b..39aa6ef98 100644 --- a/frontend/src/app/event/event.model.ts +++ b/frontend/src/app/event/event.model.ts @@ -7,6 +7,7 @@ * @license MIT */ +import { Profile } from '../models.module'; import { Organization } from '../organization/organization.model'; /** Interface for Event Type (used on frontend for event detail) */ @@ -17,8 +18,14 @@ export interface Event { location: string; description: string; public: boolean; + registration_limit: number; + can_register: boolean; organization_id: number | null; organization: Organization | null; + registration_count: number; + is_attendee: boolean; + is_organizer: boolean; + organizers: EventOrganizer[]; } /** Interface for the Event JSON Response model @@ -33,8 +40,14 @@ export interface EventJson { location: string; description: string; public: boolean; + registration_limit: number; + can_register: boolean; organization_id: number | null; organization: Organization | null; + registration_count: number; + is_attendee: boolean; + is_organizer: boolean; + organizers: EventOrganizer[]; } /** Function that converts an EventJSON response model to an Event model. @@ -45,3 +58,29 @@ export interface EventJson { export const parseEventJson = (eventJson: EventJson): Event => { return Object.assign({}, eventJson, { time: new Date(eventJson.time) }); }; + +export enum RegistrationType { + ATTENDEE, + ORGANIZER +} + +export interface EventRegistration { + id: number | null; + event_id: number; + user_id: number; + event: Event | null; + user: Profile | null; + is_organizer: boolean | null; +} + +export interface EventMember { + id: number; + registration_type: RegistrationType; +} + +export interface EventOrganizer extends EventMember { + first_name: string; + last_name: string; + pronouns: string; + email: string; +} diff --git a/frontend/src/app/event/event.module.ts b/frontend/src/app/event/event.module.ts index ce3fc92e9..3d614906f 100644 --- a/frontend/src/app/event/event.module.ts +++ b/frontend/src/app/event/event.module.ts @@ -27,6 +27,7 @@ import { FormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatChipsModule } from '@angular/material/chips'; /* UI Widgets */ import { RouterModule } from '@angular/router'; @@ -37,19 +38,23 @@ import { EventDetailsComponent } from './event-details/event-details.component'; import { EventPageComponent } from './event-page/event-page.component'; import { EventFilterPipe } from './event-filter/event-filter.pipe'; import { EventEditorComponent } from './event-editor/event-editor.component'; +import { EventUsersList } from './widgets/event-users-list/event-users-list.widget'; @NgModule({ declarations: [ EventDetailCard, EventDetailsComponent, EventPageComponent, - EventEditorComponent + EventEditorComponent, + + EventUsersList ], imports: [ CommonModule, MatTabsModule, MatTableModule, MatCardModule, + MatChipsModule, MatDialogModule, MatButtonModule, MatSelectModule, diff --git a/frontend/src/app/event/event.resolver.ts b/frontend/src/app/event/event.resolver.ts index 74905b72a..494d3d93d 100644 --- a/frontend/src/app/event/event.resolver.ts +++ b/frontend/src/app/event/event.resolver.ts @@ -32,8 +32,14 @@ export const eventDetailResolver: ResolveFn = ( location: '', description: '', public: true, + registration_limit: 0, + can_register: false, organization_id: null, - organization: null + organization: null, + registration_count: 0, + is_attendee: false, + is_organizer: false, + organizers: [] }; } }; diff --git a/frontend/src/app/event/event.service.ts b/frontend/src/app/event/event.service.ts index 83f47a06d..f57e6bcb7 100644 --- a/frontend/src/app/event/event.service.ts +++ b/frontend/src/app/event/event.service.ts @@ -9,28 +9,66 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable, map } from 'rxjs'; -import { Event, EventJson, parseEventJson } from './event.model'; +import { Observable, Subscription, map, tap } from 'rxjs'; +import { + Event, + EventJson, + EventRegistration, + parseEventJson +} from './event.model'; import { DatePipe } from '@angular/common'; import { EventFilterPipe } from './event-filter/event-filter.pipe'; +import { Profile, ProfileService } from '../profile/profile.service'; +import { Paginated, PaginationParams } from '../pagination'; @Injectable({ providedIn: 'root' }) export class EventService { + private profile: Profile | undefined; + private profileSubscription!: Subscription; + constructor( protected http: HttpClient, + protected profileSvc: ProfileService, public datePipe: DatePipe, public eventFilterPipe: EventFilterPipe - ) {} + ) { + this.profileSubscription = this.profileSvc.profile$.subscribe( + (profile) => (this.profile = profile) + ); + } + + /** Returns paginated user entries from the backend database table using the backend HTTP get request. + * @returns {Observable>} + */ + getRegisteredUsersForEvent(event_id: number, params: PaginationParams) { + let paramStrings = { + page: params.page.toString(), + page_size: params.page_size.toString(), + order_by: params.order_by, + filter: params.filter + }; + let query = new URLSearchParams(paramStrings); + return this.http.get>( + `/api/events/${event_id}/registrations/users?` + query.toString() + ); + } /** Returns all event entries from the backend database table using the backend HTTP get request. * @returns {Observable} */ getEvents(): Observable { - return this.http - .get('/api/events') - .pipe(map((eventJsons) => eventJsons.map(parseEventJson))); + if (this.profile) { + return this.http + .get('/api/events/range') + .pipe(map((eventJsons) => eventJsons.map(parseEventJson))); + } else { + // if a user isn't logged in, return the normal endpoint without registration statuses + return this.http + .get('/api/events/range/unauthenticated') + .pipe(map((eventJsons) => eventJsons.map(parseEventJson))); + } } /** Returns the event object from the backend database table using the backend HTTP get request. @@ -38,9 +76,15 @@ export class EventService { * @returns {Observable} */ getEvent(id: number): Observable { - return this.http - .get('/api/events/' + id) - .pipe(map((eventJson) => parseEventJson(eventJson))); + if (this.profile) { + return this.http + .get('/api/events/' + id) + .pipe(map((eventJson) => parseEventJson(eventJson))); + } else { + return this.http + .get('/api/events/' + id + '/unauthenticated') + .pipe(map((eventJson) => parseEventJson(eventJson))); + } } /** Returns the event object from the backend database table using the backend HTTP get request. @@ -48,9 +92,17 @@ export class EventService { * @returns {Observable} */ getEventsByOrganization(slug: string): Observable { - return this.http - .get('/api/events/organization/' + slug) - .pipe(map((eventJsons) => eventJsons.map(parseEventJson))); + if (this.profile) { + return this.http + .get('/api/events/organization/' + slug) + .pipe(map((eventJsons) => eventJsons.map(parseEventJson))); + } else { + return this.http + .get( + '/api/events/organization/' + slug + '/unauthenticated' + ) + .pipe(map((eventJsons) => eventJsons.map(parseEventJson))); + } } /** Returns the new event object from the backend database table using the backend HTTP get request. @@ -100,4 +152,62 @@ export class EventService { // Return the groups return [...groups.entries()]; } + + // Event Registration Methods + /** Return an event registration if the user is registered for an event using the backend HTTP get request. + * @param event_id: number representing the Event ID + * @returns Observable + */ + getEventRegistrationOfUser(event_id: number): Observable { + return this.http.get( + `/api/events/${event_id}/registration` + ); + } + + /** Return all event registrations an event using the backend HTTP get request. + * @param event_id: number representing the Event ID + * @returns Observable + */ + getEventRegistrations(event_id: number): Observable { + return this.http.get( + `/api/events/${event_id}/registrations` + ); + } + + /** Return number of event registrations for an event + * @param event_id: number representing the Event ID + * @returns Observable + */ + getEventRegistrationCount(event_id: number): Observable { + return this.http.get(`/api/events/${event_id}/registration/count`); + } + + /** Create a new registration for an event using the backend HTTP create request. + * @param event_id: number representing the Event ID + * @returns Observable + */ + registerForEvent(event_id: number): Observable { + if (this.profile === undefined) { + throw new Error('Only allowed for logged in users.'); + } + + return this.http.post( + `/api/events/${event_id}/registration`, + {} + ); + } + + /** Delete an existing registration for an event using the backend HTTP delete request. + * @param event_registration_id: number representing the Event Registration ID + * @returns void + */ + unregisterForEvent(event_id: number) { + if (this.profile === undefined) { + throw new Error('Only allowed for logged in users.'); + } + + return this.http.delete( + `/api/events/${event_id}/registration` + ); + } } diff --git a/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.css b/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.css index e103c1940..1d15434dd 100644 --- a/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.css +++ b/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.css @@ -1,80 +1,76 @@ .event-detail-card { - padding: 4px 16px; - margin: 0; - height: 100%; + padding: 4px 16px; + margin: 0; + height: 100%; } #top-divider { - margin: 0; + margin: 0; } .organization-section { - display: flex; - flex-direction: row; - align-items: center; - margin: 0px; - width: 100%; + display: flex; + flex-direction: row; + align-items: center; + margin: 0px; + width: 100%; } .organization-section:hover { - background-color: #525252; - cursor: pointer; + background-color: #525252; + cursor: pointer; } .event-title-container { - display: flex; - flex-direction: row; - width: 100%; - align-items: center; - margin: 0px; + display: flex; + flex-direction: row; + width: 100%; + align-items: center; + margin: 0px; } .event-title { - width: 75%; - display: flex; - flex-direction: column; + width: 75%; + display: flex; + flex-direction: column; +} + +.event-title:hover { + cursor: pointer; } .event-actions { - width: 25%; - display: flex; + width: 25%; + display: flex; + justify-content: flex-end; } .logo { - height: auto; - max-width: 2rem; - max-height: 2rem; - border-radius: 2rem; + height: auto; + max-width: 2rem; + max-height: 2rem; + border-radius: 2rem; } #organization-name { - margin-left: 1rem; + margin-left: 1rem; } -.paddded-divier { - padding-bottom: 8px; +.paddded-divider { + padding-bottom: 8px; } -.registration-listing { - display: flex; - flex-direction: row; - align-items: center; +@media (prefers-color-scheme: light) { + .organization-section:hover { + background-color: #f5f5f5; + } } -.seat-count-container { - display: flex; - flex-direction: column; - width: 75%; +.event-description { + display: flex; + flex-direction: column; } -.register-button-container { - display: flex; - flex-direction: column; - width: 25%; +.registration-information { + padding-bottom: 16px; } - -@media (prefers-color-scheme: light) { - .organization-section:hover { - background-color: #f5f5f5; - } -} \ No newline at end of file diff --git a/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.html b/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.html index 180ca25a5..d2b00204a 100644 --- a/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.html +++ b/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.html @@ -4,14 +4,16 @@
- {{ event.name }} + + {{ event.name }} +
@@ -64,5 +64,32 @@ -

{{ event.description }}

+
+

{{ event.description }}

+
+ + +
+ +

+ Seats Remaining: + {{ event.registration_limit - event.registration_count }} / {{ + event.registration_limit }} +

+ + + + +
diff --git a/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.ts b/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.ts index e54dc0c73..2a55af6b9 100644 --- a/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.ts +++ b/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.ts @@ -2,36 +2,41 @@ * The Event Detail Card widget abstracts the implementation of the * detail event card from the whole event page. * - * @author Ajay Gandecha + * @author Ajay Gandecha, Jade Keegan * @copyright 2023 * @license MIT */ -import { Component, Input } from '@angular/core'; -import { Event } from '../../event.model'; +import { Component, Input, OnInit } from '@angular/core'; +import { Event, EventRegistration } from '../../event.model'; import { MatSnackBar } from '@angular/material/snack-bar'; import { EventService } from '../../event.service'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { PermissionService } from 'src/app/permission.service'; +import { Profile } from 'src/app/models.module'; +import { Router } from '@angular/router'; @Component({ selector: 'event-detail-card', templateUrl: './event-detail-card.widget.html', styleUrls: ['./event-detail-card.widget.css'] }) -export class EventDetailCard { +export class EventDetailCard implements OnInit { /** The event for the event card to display */ @Input() event!: Event; + @Input() profile!: Profile; + adminPermission$!: Observable; /** Constructs the widget */ constructor( protected snackBar: MatSnackBar, - private eventService: EventService, - private permission: PermissionService + protected eventService: EventService, + private permission: PermissionService, + private router: Router ) {} - checkPermissions(): Observable { - return this.permission.check( + ngOnInit() { + this.adminPermission$ = this.permission.check( 'organization.events.*', `organization/${this.event.organization_id!}` ); @@ -59,13 +64,70 @@ export class EventDetailCard { deleteEvent(event: Event): void { let confirmDelete = this.snackBar.open( 'Are you sure you want to delete this event?', - 'Delete' + 'Delete', + { duration: 15000 } ); confirmDelete.onAction().subscribe(() => { this.eventService.deleteEvent(event).subscribe(() => { this.snackBar.open('Event Deleted', '', { duration: 2000 }); - location.reload(); + this.router.navigateByUrl('/events'); }); }); } + + /** Registers a user for the given event + * @param event_id: number representing the id of the Event to register the User for + */ + registerForEvent(event_id: number) { + let confirmRegistration = this.snackBar.open( + 'Are you sure you want to register for this event?', + 'Register' + ); + confirmRegistration.onAction().subscribe(() => { + this.eventService.registerForEvent(event_id).subscribe({ + next: (event_registration) => this.onSuccess(event_registration), + error: (err) => this.onError(err) + }); + }); + } + + /** Registers a user for the given event + * @param event_id: number representing the id of the Event to register the User for + */ + unregisterForEvent(event_registration_id: number) { + let confirmUnregistration = this.snackBar.open( + 'Are you sure you want to unregister for this event?', + 'Unregister', + { duration: 15000 } + ); + confirmUnregistration.onAction().subscribe(() => { + this.eventService + .unregisterForEvent(event_registration_id) + .subscribe(() => { + this.event.is_attendee = false; + this.event.registration_count -= 1; + this.snackBar.open('Successfully Unregistered!', '', { + duration: 2000 + }); + }); + }); + } + + /** Opens a confirmation snackbar when an event is successfully created. + * @returns {void} + */ + private onSuccess(event_registration: EventRegistration): void { + this.event.is_attendee = true; + this.event.registration_count += 1; + this.snackBar.open('Thanks for registering!', '', { duration: 2000 }); + } + + /** Opens a confirmation snackbar when there is an error creating an event. + * @returns {void} + */ + private onError(err: any): void { + this.snackBar.open('Error: Event Not Registered For', '', { + duration: 2000 + }); + } } diff --git a/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.css b/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.css new file mode 100644 index 000000000..e65652522 --- /dev/null +++ b/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.css @@ -0,0 +1,29 @@ +.registrations-list { + padding: 4px 16px; + margin: 0; + height: 100%; +} + +.registrations-title-container { + display: flex; + flex-direction: row; + width: 100%; + align-items: center; + margin: 0px; +} + +.registrations-title { + width: 75%; + display: flex; + flex-direction: column; +} + +.registrations-actions { + width: 25%; + display: flex; + justify-content: flex-end; +} + +#top-divider { + margin: 0; +} diff --git a/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.html b/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.html new file mode 100644 index 000000000..f08c73306 --- /dev/null +++ b/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.html @@ -0,0 +1,48 @@ + +
+
+ Registrations +
+ + + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + +
First Name{{ element.first_name }}Last Name{{ element.last_name }}Pronouns{{ element.pronouns }}Email{{ element.email }}
+ +
+
diff --git a/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.ts b/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.ts new file mode 100644 index 000000000..c40b19c31 --- /dev/null +++ b/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.ts @@ -0,0 +1,59 @@ +/** + * The Event Users List widget displays the registered users + * for an event in a paginated. + * + * @author Jade Keegan + * @copyright 2023 + * @license MIT + */ + +import { Component, Input, OnInit } from '@angular/core'; +import { PageEvent } from '@angular/material/paginator'; +import { Paginated } from 'src/app/pagination'; +import { Profile } from 'src/app/models.module'; +import { EventService } from '../../event.service'; +import { Event } from '../../event.model'; + +@Component({ + selector: 'event-users-list', + templateUrl: './event-users-list.widget.html', + styleUrls: ['./event-users-list.widget.css'] +}) +export class EventUsersList implements OnInit { + @Input() event!: Event; + page!: Paginated; + + public displayedColumns: string[] = [ + 'first_name', + 'last_name', + 'pronouns', + 'email' + ]; + + private static PaginationParams = { + page: 0, + page_size: 10, + order_by: 'first_name', + filter: '' + }; + + constructor(private eventService: EventService) {} + + ngOnInit() { + this.eventService + .getRegisteredUsersForEvent( + this.event.id!, + EventUsersList.PaginationParams + ) + .subscribe((page) => (this.page = page)); + } + + handlePageEvent(e: PageEvent) { + let paginationParams = this.page.params; + paginationParams.page = e.pageIndex; + paginationParams.page_size = e.pageSize; + this.eventService + .getRegisteredUsersForEvent(this.event.id!, paginationParams) + .subscribe((page) => (this.page = page)); + } +} diff --git a/frontend/src/app/organization/rx-organization.ts b/frontend/src/app/organization/rx-organization.ts index 09014cc12..77464ebaf 100644 --- a/frontend/src/app/organization/rx-organization.ts +++ b/frontend/src/app/organization/rx-organization.ts @@ -1,3 +1,12 @@ +/** + * The RxOrganization object is used to ensure proper updating and + * retrieval of the list of all organizations in the database. + * + * @author Jade Keegan + * @copyright 2023 + * @license MIT + */ + import { RxObject } from '../rx-object'; import { Organization } from './organization.model';