Skip to content

Commit 3335243

Browse files
authored
Merge pull request #29 from vintasoftware/revert-28-revert-24-feat/calendar-groups-and-slots-pt4
feat: add CalendarGroup to Public API (mutations and queries)
2 parents deb41e5 + 32df8cf commit 3335243

9 files changed

Lines changed: 1005 additions & 6 deletions

File tree

calendar_integration/graphql.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
BlockedTimeRecurrenceException,
1111
Calendar,
1212
CalendarEvent,
13+
CalendarEventGroupSelection,
14+
CalendarGroup,
15+
CalendarGroupSlot,
1316
CalendarWebhookEvent,
1417
CalendarWebhookSubscription,
1518
EventAttendance,
@@ -146,6 +149,10 @@ class CalendarEventGraphQLType:
146149
external_attendees: list[ExternalAttendeeGraphQLType] = strawberry_django.field()
147150
resources: list[CalendarGraphQLType] = strawberry_django.field()
148151

152+
# Calendar group linkage (when booked through a CalendarGroup)
153+
calendar_group: "CalendarGroupGraphQLType | None" = strawberry_django.field()
154+
group_selections: list["CalendarEventGroupSelectionGraphQLType"] = strawberry_django.field()
155+
149156
# Bundle representations - events that represent this primary event in child calendars
150157
bundle_representations: list["CalendarEventGraphQLType"] = strawberry_django.field()
151158

@@ -308,3 +315,65 @@ class WebhookSubscriptionStatusGraphQLType:
308315
recent_events_count: int # events in last 24 hours
309316
failed_events_count: int # failed events in last 24 hours
310317
success_rate: float # percentage of successful events in last 24 hours
318+
319+
320+
# ---------------------------------------------------------------------------
321+
# CalendarGroup types
322+
# ---------------------------------------------------------------------------
323+
@strawberry_django.type(CalendarGroupSlot)
324+
class CalendarGroupSlotGraphQLType:
325+
id: strawberry.auto # noqa: A003
326+
name: strawberry.auto
327+
description: strawberry.auto
328+
order: strawberry.auto
329+
required_count: strawberry.auto
330+
created: datetime.datetime
331+
modified: datetime.datetime
332+
333+
calendars: list[CalendarGraphQLType] = strawberry_django.field()
334+
335+
336+
@strawberry_django.type(CalendarGroup)
337+
class CalendarGroupGraphQLType:
338+
id: strawberry.auto # noqa: A003
339+
name: strawberry.auto
340+
description: strawberry.auto
341+
created: datetime.datetime
342+
modified: datetime.datetime
343+
344+
slots: list[CalendarGroupSlotGraphQLType] = strawberry_django.field()
345+
346+
347+
@strawberry_django.type(CalendarEventGroupSelection)
348+
class CalendarEventGroupSelectionGraphQLType:
349+
id: strawberry.auto # noqa: A003
350+
created: datetime.datetime
351+
modified: datetime.datetime
352+
353+
slot: CalendarGroupSlotGraphQLType = strawberry_django.field()
354+
calendar: CalendarGraphQLType = strawberry_django.field()
355+
356+
357+
@strawberry.type
358+
class CalendarGroupSlotAvailabilityGraphQLType:
359+
"""How many of a slot's pool calendars are available for a given range."""
360+
361+
slot_id: int
362+
available_calendar_ids: list[int]
363+
364+
365+
@strawberry.type
366+
class CalendarGroupRangeAvailabilityGraphQLType:
367+
"""Per-slot availability for a single range."""
368+
369+
start_time: datetime.datetime
370+
end_time: datetime.datetime
371+
slots: list[CalendarGroupSlotAvailabilityGraphQLType]
372+
373+
374+
@strawberry.type
375+
class BookableSlotProposalGraphQLType:
376+
"""A concrete time window where every slot in a group is satisfied."""
377+
378+
start_time: datetime.datetime
379+
end_time: datetime.datetime

calendar_integration/mutations.py

Lines changed: 274 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,35 @@
11
"""GraphQL mutations for calendar integration webhook management."""
22

3+
import datetime
34
from dataclasses import dataclass
45
from typing import TYPE_CHECKING, Annotated, cast
56

67
import strawberry
78
from dependency_injector.wiring import Provide, inject
89
from graphql import GraphQLError
910

10-
from calendar_integration.graphql import CalendarWebhookSubscriptionGraphQLType
11+
from calendar_integration.exceptions import CalendarGroupError
12+
from calendar_integration.graphql import (
13+
CalendarEventGraphQLType,
14+
CalendarGroupGraphQLType,
15+
CalendarWebhookSubscriptionGraphQLType,
16+
)
17+
from calendar_integration.models import CalendarGroup
18+
from calendar_integration.services.dataclasses import (
19+
CalendarGroupEventInputData,
20+
CalendarGroupInputData,
21+
CalendarGroupSlotInputData,
22+
CalendarGroupSlotSelectionInputData,
23+
EventAttendanceInputData,
24+
EventExternalAttendanceInputData,
25+
ExternalAttendeeInputData,
26+
)
1127
from calendar_integration.services.webhook_analytics_service import WebhookAnalyticsService
1228
from organizations.models import Organization
1329

1430

1531
if TYPE_CHECKING:
32+
from calendar_integration.services.calendar_group_service import CalendarGroupService
1633
from calendar_integration.services.calendar_service import CalendarService
1734

1835

@@ -220,3 +237,259 @@ def cleanup_webhook_events(
220237
deleted_count=0,
221238
error_message=f"Failed to cleanup events: {e!s}",
222239
)
240+
241+
242+
# ---------------------------------------------------------------------------
243+
# CalendarGroup mutations
244+
# ---------------------------------------------------------------------------
245+
246+
247+
@dataclass
248+
class CalendarGroupMutationDependencies:
249+
"""Dependencies for CalendarGroup mutations."""
250+
251+
calendar_group_service: "CalendarGroupService"
252+
calendar_service: "CalendarService"
253+
254+
255+
@inject
256+
def get_calendar_group_mutation_dependencies(
257+
calendar_group_service: Annotated[
258+
"CalendarGroupService | None", Provide["calendar_group_service"]
259+
] = None,
260+
calendar_service: Annotated["CalendarService | None", Provide["calendar_service"]] = None,
261+
) -> CalendarGroupMutationDependencies:
262+
required = [calendar_group_service, calendar_service]
263+
if any(dep is None for dep in required):
264+
raise GraphQLError(
265+
f"Missing required dependency {', '.join([str(d) for d in required if d is None])}"
266+
)
267+
return CalendarGroupMutationDependencies(
268+
calendar_group_service=cast("CalendarGroupService", calendar_group_service),
269+
calendar_service=cast("CalendarService", calendar_service),
270+
)
271+
272+
273+
@strawberry.input
274+
class CalendarGroupSlotInput:
275+
name: str
276+
calendar_ids: list[int]
277+
required_count: int = 1
278+
description: str = ""
279+
order: int = 0
280+
281+
282+
@strawberry.input
283+
class CalendarGroupInput:
284+
organization_id: int
285+
name: str
286+
description: str = ""
287+
slots: list[CalendarGroupSlotInput] = strawberry.field(default_factory=list)
288+
289+
290+
@strawberry.input
291+
class UpdateCalendarGroupInput:
292+
organization_id: int
293+
group_id: int
294+
name: str
295+
description: str = ""
296+
slots: list[CalendarGroupSlotInput] = strawberry.field(default_factory=list)
297+
298+
299+
@strawberry.input
300+
class DeleteCalendarGroupInput:
301+
organization_id: int
302+
group_id: int
303+
304+
305+
@strawberry.input
306+
class CalendarGroupSlotSelectionInput:
307+
slot_id: int
308+
calendar_ids: list[int]
309+
310+
311+
@strawberry.input
312+
class ExternalAttendeeInput:
313+
email: str
314+
name: str = ""
315+
id: int | None = None # noqa: A003
316+
317+
318+
@strawberry.input
319+
class EventExternalAttendanceInput:
320+
external_attendee: ExternalAttendeeInput
321+
322+
323+
@strawberry.input
324+
class EventAttendanceInput:
325+
user_id: int
326+
327+
328+
@strawberry.input
329+
class CalendarGroupEventInput:
330+
organization_id: int
331+
group_id: int
332+
title: str
333+
description: str
334+
start_time: datetime.datetime
335+
end_time: datetime.datetime
336+
timezone: str
337+
slot_selections: list[CalendarGroupSlotSelectionInput]
338+
attendances: list[EventAttendanceInput] = strawberry.field(default_factory=list)
339+
external_attendances: list[EventExternalAttendanceInput] = strawberry.field(
340+
default_factory=list
341+
)
342+
343+
344+
@strawberry.type
345+
class CalendarGroupResult:
346+
success: bool
347+
group: CalendarGroupGraphQLType | None = None
348+
error_message: str | None = None
349+
350+
351+
@strawberry.type
352+
class DeleteCalendarGroupResult:
353+
success: bool
354+
error_message: str | None = None
355+
356+
357+
@strawberry.type
358+
class CalendarGroupEventResult:
359+
success: bool
360+
event: CalendarEventGraphQLType | None = None
361+
error_message: str | None = None
362+
363+
364+
def _to_slot_input_data(slots: list[CalendarGroupSlotInput]) -> list[CalendarGroupSlotInputData]:
365+
return [
366+
CalendarGroupSlotInputData(
367+
name=s.name,
368+
calendar_ids=list(s.calendar_ids),
369+
required_count=s.required_count,
370+
description=s.description,
371+
order=s.order,
372+
)
373+
for s in slots
374+
]
375+
376+
377+
def _load_organization(organization_id: int) -> Organization | None:
378+
try:
379+
return Organization.objects.get(id=organization_id)
380+
except Organization.DoesNotExist:
381+
return None
382+
383+
384+
@strawberry.type
385+
class CalendarGroupMutations:
386+
"""GraphQL mutations for CalendarGroup CRUD and grouped event booking."""
387+
388+
@strawberry.mutation
389+
def create_calendar_group(
390+
self,
391+
input: CalendarGroupInput, # noqa: A002
392+
) -> CalendarGroupResult:
393+
organization = _load_organization(input.organization_id)
394+
if organization is None:
395+
return CalendarGroupResult(success=False, error_message="Organization not found")
396+
deps = get_calendar_group_mutation_dependencies()
397+
deps.calendar_group_service.initialize(organization=organization)
398+
try:
399+
group = deps.calendar_group_service.create_group(
400+
CalendarGroupInputData(
401+
name=input.name,
402+
description=input.description,
403+
slots=_to_slot_input_data(input.slots),
404+
)
405+
)
406+
except CalendarGroupError as e:
407+
return CalendarGroupResult(success=False, error_message=str(e))
408+
return CalendarGroupResult(success=True, group=group) # type: ignore[arg-type]
409+
410+
@strawberry.mutation
411+
def update_calendar_group(
412+
self,
413+
input: UpdateCalendarGroupInput, # noqa: A002
414+
) -> CalendarGroupResult:
415+
organization = _load_organization(input.organization_id)
416+
if organization is None:
417+
return CalendarGroupResult(success=False, error_message="Organization not found")
418+
deps = get_calendar_group_mutation_dependencies()
419+
deps.calendar_group_service.initialize(organization=organization)
420+
try:
421+
group = deps.calendar_group_service.update_group(
422+
group_id=input.group_id,
423+
data=CalendarGroupInputData(
424+
name=input.name,
425+
description=input.description,
426+
slots=_to_slot_input_data(input.slots),
427+
),
428+
)
429+
except CalendarGroup.DoesNotExist:
430+
return CalendarGroupResult(success=False, error_message="Group not found")
431+
except CalendarGroupError as e:
432+
return CalendarGroupResult(success=False, error_message=str(e))
433+
return CalendarGroupResult(success=True, group=group) # type: ignore[arg-type]
434+
435+
@strawberry.mutation
436+
def delete_calendar_group(
437+
self,
438+
input: DeleteCalendarGroupInput, # noqa: A002
439+
) -> DeleteCalendarGroupResult:
440+
organization = _load_organization(input.organization_id)
441+
if organization is None:
442+
return DeleteCalendarGroupResult(success=False, error_message="Organization not found")
443+
deps = get_calendar_group_mutation_dependencies()
444+
deps.calendar_group_service.initialize(organization=organization)
445+
try:
446+
deps.calendar_group_service.delete_group(group_id=input.group_id)
447+
except CalendarGroup.DoesNotExist:
448+
return DeleteCalendarGroupResult(success=False, error_message="Group not found")
449+
except CalendarGroupError as e:
450+
return DeleteCalendarGroupResult(success=False, error_message=str(e))
451+
return DeleteCalendarGroupResult(success=True)
452+
453+
@strawberry.mutation
454+
def create_calendar_group_event(
455+
self,
456+
input: CalendarGroupEventInput, # noqa: A002
457+
) -> CalendarGroupEventResult:
458+
organization = _load_organization(input.organization_id)
459+
if organization is None:
460+
return CalendarGroupEventResult(success=False, error_message="Organization not found")
461+
deps = get_calendar_group_mutation_dependencies()
462+
deps.calendar_service.initialize_without_provider(organization=organization)
463+
deps.calendar_group_service.initialize(organization=organization)
464+
data = CalendarGroupEventInputData(
465+
title=input.title,
466+
description=input.description,
467+
start_time=input.start_time,
468+
end_time=input.end_time,
469+
timezone=input.timezone,
470+
group_id=input.group_id,
471+
slot_selections=[
472+
CalendarGroupSlotSelectionInputData(
473+
slot_id=s.slot_id, calendar_ids=list(s.calendar_ids)
474+
)
475+
for s in input.slot_selections
476+
],
477+
attendances=[EventAttendanceInputData(user_id=a.user_id) for a in input.attendances],
478+
external_attendances=[
479+
EventExternalAttendanceInputData(
480+
external_attendee=ExternalAttendeeInputData(
481+
email=e.external_attendee.email,
482+
name=e.external_attendee.name,
483+
id=e.external_attendee.id,
484+
)
485+
)
486+
for e in input.external_attendances
487+
],
488+
)
489+
try:
490+
event = deps.calendar_group_service.create_grouped_event(data)
491+
except CalendarGroup.DoesNotExist:
492+
return CalendarGroupEventResult(success=False, error_message="Group not found")
493+
except CalendarGroupError as e:
494+
return CalendarGroupEventResult(success=False, error_message=str(e))
495+
return CalendarGroupEventResult(success=True, event=event) # type: ignore[arg-type]

0 commit comments

Comments
 (0)