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.
+
+
+
+ 0">
+
+
+
+
+
+
+
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);
}
}