diff --git a/backend/api/coworking/ambassador.py b/backend/api/coworking/ambassador.py index ed3c10360..cc6e9e9ca 100644 --- a/backend/api/coworking/ambassador.py +++ b/backend/api/coworking/ambassador.py @@ -7,10 +7,10 @@ from ..authentication import registered_user from ...services.coworking.reservation import ReservationService from ...models import User -from ...models.coworking import Reservation, ReservationPartial +from ...models.coworking import Reservation, ReservationPartial, ReservationRequest, ReservationState __authors__ = ["Kris Jordan"] -__copyright__ = "Copyright 2023" +__copyright__ = "Copyright 2023 - 2024" __license__ = "MIT" @@ -36,3 +36,22 @@ def checkin_reservation( ) -> Reservation: """CheckIn a confirmed reservation.""" return reservation_svc.staff_checkin_reservation(subject, reservation) + + +@api.post("/reservation", tags=["Coworking"]) +def create_walkin_reservation( + reservation_request: ReservationRequest, + subject: User = Depends(registered_user), + reservation_svc: ReservationService = Depends(), +) -> Reservation: + """Create a walk-in reservation as an ambassador for a user showing up to the desk + without having drafted/confirmed a reservation of their own ahead of time.""" + # TODO: The efficiency of this operation could be improved with a custom method, but since this + # happens at the speed of an ambassador manually checking someone in (and is the sequence of steps + # that normally take place otherwise), reusing existing methods here is fine for now. + reservation_draft = reservation_svc.draft_reservation(subject, reservation_request) + # Confirm the Draft Reservation + reservation_partial = ReservationPartial(id=reservation_draft.id, state=ReservationState.CONFIRMED) + reservation_confirmed = reservation_svc.change_reservation(subject, reservation_partial) + # Check Reservation In + return reservation_svc.staff_checkin_reservation(subject, reservation_confirmed) diff --git a/backend/services/user.py b/backend/services/user.py index e190fbb9d..2c7caad99 100644 --- a/backend/services/user.py +++ b/backend/services/user.py @@ -4,7 +4,7 @@ """ from fastapi import Depends -from sqlalchemy import select, or_, func +from sqlalchemy import select, or_, func, cast, String from sqlalchemy.orm import Session from ..database import db_session from ..models import User, UserDetails, Paginated, PaginationParams @@ -84,6 +84,7 @@ def search(self, _subject: User, query: str) -> list[User]: UserEntity.last_name.ilike(f"%{query}%"), UserEntity.onyen.ilike(f"%{query}%"), UserEntity.email.ilike(f"%{query}%"), + cast(UserEntity.pid, String).ilike(f"%{query}%") ) statement = statement.where(criteria).limit(10) entities = self._session.execute(statement).scalars() diff --git a/backend/test/services/user_test.py b/backend/test/services/user_test.py index c43f01527..2d41c833b 100644 --- a/backend/test/services/user_test.py +++ b/backend/test/services/user_test.py @@ -110,6 +110,17 @@ def test_search_no_match(user_svc: UserService): assert len(users) == 0 +def test_search_by_pid_does_not_exist(user_svc: UserService): + """Test searching for a partial PID that does not exist.""" + users = user_svc.search(ambassador, "123") + assert len(users) == 0 + +def test_search_by_pid_rhonda(user_svc: UserService): + """Test searching for a partial PID that does exist.""" + users = user_svc.search(ambassador, "999") + assert len(users) == 1 + assert users[0] == root + def test_list(user_svc: UserService): """Test that a paginated list of users can be produced.""" pagination_params = PaginationParams(page=0, page_size=2, order_by="id", filter="") diff --git a/frontend/src/app/admin/roles/details/admin-role-details.component.ts b/frontend/src/app/admin/roles/details/admin-role-details.component.ts index c37d37e35..2c9a2bfb9 100644 --- a/frontend/src/app/admin/roles/details/admin-role-details.component.ts +++ b/frontend/src/app/admin/roles/details/admin-role-details.component.ts @@ -1,9 +1,6 @@ import { Component, inject, OnInit } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { - MatAutocompleteActivatedEvent, - MatAutocompleteSelectedEvent -} from '@angular/material/autocomplete'; +import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { ActivatedRoute, ActivatedRouteSnapshot, diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-home.component.css b/frontend/src/app/coworking/ambassador-home/ambassador-home.component.css index 02b2bd864..79b4759b9 100644 --- a/frontend/src/app/coworking/ambassador-home/ambassador-home.component.css +++ b/frontend/src/app/coworking/ambassador-home/ambassador-home.component.css @@ -1,7 +1,15 @@ .mat-mdc-card { - max-width: 100%; + max-width: 100%; +} + +.mat-mdc-card-header { + margin-bottom: 16px; +} + +.walkinReservation.mat-mdc-card-content:last-child { + padding-bottom: 0; } button { - margin-right: 1vw; -} \ No newline at end of file + margin-right: 1vw; +} diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-home.component.html b/frontend/src/app/coworking/ambassador-home/ambassador-home.component.html index 58a125e64..429470d20 100644 --- a/frontend/src/app/coworking/ambassador-home/ambassador-home.component.html +++ b/frontend/src/app/coworking/ambassador-home/ambassador-home.component.html @@ -1,3 +1,29 @@ +
+ + + Reserve a Drop-in at the Welcome Desk + Create a walk-in reservation for an XL community member at the welcome + desk. Members must be registered with the XL and accept the Community + Agreement. + + + + + + + + + + +
Name - {{ reservation.users[0].first_name }} - {{ reservation.users[0].last_name }} + {{ reservation.users[0].first_name }} {{ + reservation.users[0].last_name }} @@ -87,8 +113,8 @@ Name - {{ reservation.users[0].first_name }} - {{ reservation.users[0].last_name }} + {{ reservation.users[0].first_name }} {{ + reservation.users[0].last_name }} diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-home.component.ts b/frontend/src/app/coworking/ambassador-home/ambassador-home.component.ts index 37d13553a..b60e64aa6 100644 --- a/frontend/src/app/coworking/ambassador-home/ambassador-home.component.ts +++ b/frontend/src/app/coworking/ambassador-home/ambassador-home.component.ts @@ -1,10 +1,26 @@ +/** + * This component is the primary screen for ambassadors at the check-in desk. + * + * @author Kris Jordan + * @copyright 2023 - 2024 + * @license MIT + */ + import { Component, OnDestroy, OnInit } from '@angular/core'; import { Route } from '@angular/router'; import { permissionGuard } from 'src/app/permission.guard'; import { profileResolver } from 'src/app/profile/profile.resolver'; -import { Observable, Subscription, map, mergeMap, tap, timer } from 'rxjs'; -import { Reservation } from '../coworking.models'; +import { Observable, Subscription, map, tap, timer } from 'rxjs'; +import { + CoworkingStatus, + Reservation, + SeatAvailability +} from '../coworking.models'; import { AmbassadorService } from './ambassador.service'; +import { PublicProfile } from 'src/app/profile/profile.service'; +import { CoworkingService } from '../coworking.service'; + +const FIVE_SECONDS = 5 * 1000; @Component({ selector: 'app-coworking-ambassador-home', @@ -25,11 +41,17 @@ export class AmbassadorPageComponent implements OnInit, OnDestroy { upcomingReservations$: Observable; activeReservations$: Observable; + welcomeDeskReservationSelection: PublicProfile[] = []; + status$: Observable; + columnsToDisplay = ['id', 'name', 'seat', 'start', 'end', 'actions']; private refreshSubscription!: Subscription; - constructor(public ambassadorService: AmbassadorService) { + constructor( + public ambassadorService: AmbassadorService, + public coworkingService: CoworkingService + ) { this.reservations$ = this.ambassadorService.reservations$; this.upcomingReservations$ = this.reservations$.pipe( map((reservations) => reservations.filter((r) => r.state === 'CONFIRMED')) @@ -39,15 +61,60 @@ export class AmbassadorPageComponent implements OnInit, OnDestroy { reservations.filter((r) => r.state === 'CHECKED_IN') ) ); + + this.status$ = coworkingService.status$; } - ngOnInit(): void { - this.refreshSubscription = timer(0, 5000) + beginReservationRefresh(): void { + if (this.refreshSubscription) { + this.refreshSubscription.unsubscribe(); + } + this.refreshSubscription = timer(0, FIVE_SECONDS) .pipe(tap((_) => this.ambassadorService.fetchReservations())) .subscribe(); } + ngOnInit(): void { + this.beginReservationRefresh(); + } + ngOnDestroy(): void { this.refreshSubscription.unsubscribe(); } + + onUsersChanged(users: PublicProfile[]) { + if (users.length > 0) { + this.coworkingService.pollStatus(); + } + } + + onWalkinSeatSelection(seatSelection: SeatAvailability[]) { + if ( + seatSelection.length > 0 && + this.welcomeDeskReservationSelection.length > 0 + ) { + this.ambassadorService + .makeDropinReservation( + seatSelection, + this.welcomeDeskReservationSelection + ) + .subscribe({ + next: (reservation) => { + this.welcomeDeskReservationSelection = []; + this.beginReservationRefresh(); + alert( + `Walk-in reservation made for ${ + reservation.users[0].first_name + } ${ + reservation.users[0].last_name + }!\nReservation ends at ${reservation.end.toLocaleTimeString()}` + ); + }, + error: (e) => { + this.welcomeDeskReservationSelection = []; + alert(e.message + '\n\n' + e.error.message); + } + }); + } + } } diff --git a/frontend/src/app/coworking/ambassador-home/ambassador.service.ts b/frontend/src/app/coworking/ambassador-home/ambassador.service.ts index afe72ad7f..4db97e95b 100644 --- a/frontend/src/app/coworking/ambassador-home/ambassador.service.ts +++ b/frontend/src/app/coworking/ambassador-home/ambassador.service.ts @@ -4,9 +4,13 @@ import { Observable, map } from 'rxjs'; import { Reservation, ReservationJSON, + SeatAvailability, parseReservationJSON } from '../coworking.models'; import { HttpClient } from '@angular/common/http'; +import { PublicProfile } from 'src/app/profile/profile.service'; + +const ONE_HOUR = 60 * 60 * 1000; @Injectable({ providedIn: 'root' }) export class AmbassadorService { @@ -64,4 +68,27 @@ export class AmbassadorService { } }); } + + makeDropinReservation( + seatSelection: SeatAvailability[], + users: PublicProfile[] + ) { + let start = seatSelection[0].availability[0].start; + let end = new Date(start.getTime() + 2 * ONE_HOUR); + let reservation = { + users: users, + seats: seatSelection.map((seatAvailability) => { + return { id: seatAvailability.id }; + }), + start, + end + }; + + return this.http + .post( + '/api/coworking/ambassador/reservation', + reservation + ) + .pipe(map(parseReservationJSON)); + } } diff --git a/frontend/src/app/coworking/coworking.module.ts b/frontend/src/app/coworking/coworking.module.ts index ec3fec062..00bc74019 100644 --- a/frontend/src/app/coworking/coworking.module.ts +++ b/frontend/src/app/coworking/coworking.module.ts @@ -15,6 +15,11 @@ import { MatButtonModule } from '@angular/material/button'; import { MatTableModule } from '@angular/material/table'; import { ReservationComponent } from './reservation/reservation.component'; import { OperatingHoursDialog } from './widgets/operating-hours-dialog/operating-hours-dialog.widget'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatInputModule } from '@angular/material/input'; +import { SharedModule } from '../shared/shared.module'; @NgModule({ declarations: [ @@ -35,7 +40,13 @@ import { OperatingHoursDialog } from './widgets/operating-hours-dialog/operating MatExpansionModule, MatButtonModule, MatTableModule, - AsyncPipe + MatFormFieldModule, + MatInputModule, + MatAutocompleteModule, + MatCardModule, + AsyncPipe, + AsyncPipe, + SharedModule ] }) export class CoworkingModule {} 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 ead630df7..d7a3117e1 100644 --- a/frontend/src/app/event/event-editor/event-editor.component.html +++ b/frontend/src/app/event/event-editor/event-editor.component.html @@ -68,7 +68,6 @@ diff --git a/frontend/src/app/permission.service.ts b/frontend/src/app/permission.service.ts index 43484db60..d76ee0af9 100644 --- a/frontend/src/app/permission.service.ts +++ b/frontend/src/app/permission.service.ts @@ -30,6 +30,9 @@ export class PermissionService { action: string, resource: string ) { + if (!permissions) { + return false; + } let permission = permissions.find((p) => this.checkPermission(p, action, resource) ); diff --git a/frontend/src/app/shared/user-lookup/user-lookup.widget.ts b/frontend/src/app/shared/user-lookup/user-lookup.widget.ts index f5a03042b..b18c934fb 100644 --- a/frontend/src/app/shared/user-lookup/user-lookup.widget.ts +++ b/frontend/src/app/shared/user-lookup/user-lookup.widget.ts @@ -7,7 +7,15 @@ * @license MIT */ -import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; +import { + Component, + ElementRef, + EventEmitter, + Input, + OnInit, + Output, + ViewChild +} from '@angular/core'; import { FormControl } from '@angular/forms'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { @@ -29,10 +37,11 @@ import { ProfileService, PublicProfile } from 'src/app/profile/profile.service'; export class UserLookup implements OnInit { @Input() label: string = 'Users'; @Input() maxSelected: number | null = null; - @Input() profile: Profile | null = null; @Input() users: PublicProfile[] = []; @Input() disabled: boolean | null = false; + @Output() usersChanged: EventEmitter = new EventEmitter(); + userLookup = new FormControl(); @ViewChild('usersInput') usersInput!: ElementRef; @@ -72,11 +81,13 @@ export class UserLookup implements OnInit { } this.usersInput.nativeElement.value = ''; this.userLookup.setValue(''); + this.usersChanged.emit(this.users); } /** Handler for selecting an option in the who chip grid. */ public onUserRemoved(person: PublicProfile) { this.users.splice(this.users.indexOf(person), 1); this.userLookup.setValue(''); + this.usersChanged.emit(this.users); } }