Coverage for app/backend/src/couchers/servicers/events.py: 84%

525 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-23 04:10 +0000

1import logging 

2from datetime import datetime, timedelta 

3from typing import Any, cast 

4 

5import grpc 

6from google.protobuf import empty_pb2 

7from psycopg.types.range import TimestamptzRange 

8from sqlalchemy import Select, select 

9from sqlalchemy.orm import Session 

10from sqlalchemy.sql import and_, func, or_, update 

11 

12from couchers.context import CouchersContext, make_notification_user_context 

13from couchers.db import can_moderate_node, get_parent_node_at_location, session_scope 

14from couchers.event_log import log_event 

15from couchers.helpers.completed_profile import has_completed_profile 

16from couchers.jobs.enqueue import queue_job 

17from couchers.models import ( 

18 AttendeeStatus, 

19 Cluster, 

20 ClusterSubscription, 

21 Event, 

22 EventCommunityInviteRequest, 

23 EventOccurrence, 

24 EventOccurrenceAttendee, 

25 EventOrganizer, 

26 EventSubscription, 

27 ModerationObjectType, 

28 Node, 

29 NodeType, 

30 Thread, 

31 Upload, 

32 User, 

33) 

34from couchers.models.notifications import NotificationTopicAction 

35from couchers.moderation.utils import create_moderation 

36from couchers.notifications.notify import notify 

37from couchers.proto import events_pb2, events_pb2_grpc, notification_data_pb2 

38from couchers.proto.internal import jobs_pb2 

39from couchers.servicers.api import user_model_to_pb 

40from couchers.servicers.blocking import is_not_visible 

41from couchers.servicers.threads import thread_to_pb 

42from couchers.sql import users_visible, where_moderated_content_visible, where_users_column_visible 

43from couchers.tasks import send_event_community_invite_request_email 

44from couchers.utils import ( 

45 Timestamp_from_datetime, 

46 create_coordinate, 

47 dt_from_millis, 

48 millis_from_dt, 

49 not_none, 

50 now, 

51 to_aware_datetime, 

52) 

53 

54logger = logging.getLogger(__name__) 

55 

56attendancestate2sql = { 

57 events_pb2.AttendanceState.ATTENDANCE_STATE_NOT_GOING: None, 

58 events_pb2.AttendanceState.ATTENDANCE_STATE_GOING: AttendeeStatus.going, 

59} 

60 

61attendancestate2api = { 

62 None: events_pb2.AttendanceState.ATTENDANCE_STATE_NOT_GOING, 

63 AttendeeStatus.going: events_pb2.AttendanceState.ATTENDANCE_STATE_GOING, 

64} 

65 

66MAX_PAGINATION_LENGTH = 25 

67 

68 

69def _is_event_owner(event: Event, user_id: int) -> bool: 

70 """ 

71 Checks whether the user can act as an owner of the event 

72 """ 

73 if event.owner_user: 

74 return event.owner_user_id == user_id 

75 # otherwise owned by a cluster 

76 return not_none(event.owner_cluster).admins.where(User.id == user_id).one_or_none() is not None 

77 

78 

79def _is_event_organizer(event: Event, user_id: int) -> bool: 

80 """ 

81 Checks whether the user is as an organizer of the event 

82 """ 

83 return event.organizers.where(EventOrganizer.user_id == user_id).one_or_none() is not None 

84 

85 

86def _can_moderate_event(session: Session, event: Event, user_id: int) -> bool: 

87 # if the event is owned by a cluster, then any moderator of that cluster can moderate this event 

88 if event.owner_cluster is not None and can_moderate_node(session, user_id, event.owner_cluster.parent_node_id): 

89 return True 

90 

91 # finally check if the user can moderate the parent node of the cluster 

92 return can_moderate_node(session, user_id, event.parent_node_id) 

93 

94 

95def _can_edit_event(session: Session, event: Event, user_id: int) -> bool: 

96 return ( 

97 _is_event_owner(event, user_id) 

98 or _is_event_organizer(event, user_id) 

99 or _can_moderate_event(session, event, user_id) 

100 ) 

101 

102 

103def event_to_pb(session: Session, occurrence: EventOccurrence, context: CouchersContext) -> events_pb2.Event: 

104 event = occurrence.event 

105 

106 next_occurrence = ( 

107 event.occurrences.where(EventOccurrence.end_time >= now()) 

108 .order_by(EventOccurrence.end_time.asc()) 

109 .limit(1) 

110 .one_or_none() 

111 ) 

112 

113 owner_community_id = None 

114 owner_group_id = None 

115 if event.owner_cluster: 

116 if event.owner_cluster.is_official_cluster: 

117 owner_community_id = event.owner_cluster.parent_node_id 

118 else: 

119 owner_group_id = event.owner_cluster.id 

120 

121 attendance = occurrence.attendances.where(EventOccurrenceAttendee.user_id == context.user_id).one_or_none() 

122 attendance_state = attendance.attendee_status if attendance else None 

123 

124 can_moderate = _can_moderate_event(session, event, context.user_id) 

125 can_edit = _can_edit_event(session, event, context.user_id) 

126 

127 going_count = session.execute( 

128 where_users_column_visible( 

129 select(func.count()) 

130 .select_from(EventOccurrenceAttendee) 

131 .where(EventOccurrenceAttendee.occurrence_id == occurrence.id) 

132 .where(EventOccurrenceAttendee.attendee_status == AttendeeStatus.going), 

133 context, 

134 EventOccurrenceAttendee.user_id, 

135 ) 

136 ).scalar_one() 

137 organizer_count = session.execute( 

138 where_users_column_visible( 

139 select(func.count()).select_from(EventOrganizer).where(EventOrganizer.event_id == event.id), 

140 context, 

141 EventOrganizer.user_id, 

142 ) 

143 ).scalar_one() 

144 subscriber_count = session.execute( 

145 where_users_column_visible( 

146 select(func.count()).select_from(EventSubscription).where(EventSubscription.event_id == event.id), 

147 context, 

148 EventSubscription.user_id, 

149 ) 

150 ).scalar_one() 

151 

152 return events_pb2.Event( 

153 event_id=occurrence.id, 

154 is_next=False if not next_occurrence else occurrence.id == next_occurrence.id, 

155 is_cancelled=occurrence.is_cancelled, 

156 is_deleted=occurrence.is_deleted, 

157 title=event.title, 

158 slug=event.slug, 

159 content=occurrence.content, 

160 photo_url=occurrence.photo.full_url if occurrence.photo else None, 

161 photo_key=occurrence.photo_key or "", 

162 location=events_pb2.EventLocation( 

163 lat=occurrence.coordinates[0], lng=occurrence.coordinates[1], address=occurrence.address 

164 ), 

165 created=Timestamp_from_datetime(occurrence.created), 

166 last_edited=Timestamp_from_datetime(occurrence.last_edited), 

167 creator_user_id=occurrence.creator_user_id, 

168 start_time=Timestamp_from_datetime(occurrence.start_time), 

169 end_time=Timestamp_from_datetime(occurrence.end_time), 

170 timezone=occurrence.timezone, 

171 attendance_state=attendancestate2api[attendance_state], 

172 organizer=event.organizers.where(EventOrganizer.user_id == context.user_id).one_or_none() is not None, 

173 subscriber=event.subscribers.where(EventSubscription.user_id == context.user_id).one_or_none() is not None, 

174 going_count=going_count, 

175 organizer_count=organizer_count, 

176 subscriber_count=subscriber_count, 

177 owner_user_id=event.owner_user_id, 

178 owner_community_id=owner_community_id, 

179 owner_group_id=owner_group_id, 

180 thread=thread_to_pb(session, context, event.thread_id), 

181 can_edit=can_edit, 

182 can_moderate=can_moderate, 

183 ) 

184 

185 

186def _get_event_and_occurrence_query( 

187 occurrence_id: int, 

188 include_deleted: bool, 

189 context: CouchersContext | None = None, 

190) -> Select[tuple[Event, EventOccurrence]]: 

191 query = ( 

192 select(Event, EventOccurrence) 

193 .where(EventOccurrence.id == occurrence_id) 

194 .where(EventOccurrence.event_id == Event.id) 

195 ) 

196 

197 if not include_deleted: 197 ↛ 200line 197 didn't jump to line 200 because the condition on line 197 was always true

198 query = query.where(~EventOccurrence.is_deleted) 

199 

200 if context is not None: 

201 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=False) 

202 

203 return query 

204 

205 

206def _get_event_and_occurrence_one( 

207 session: Session, occurrence_id: int, include_deleted: bool = False 

208) -> tuple[Event, EventOccurrence]: 

209 """For background jobs only - no visibility filtering.""" 

210 result = session.execute(_get_event_and_occurrence_query(occurrence_id, include_deleted)).one() 

211 return result._tuple() 

212 

213 

214def _get_event_and_occurrence_one_or_none( 

215 session: Session, occurrence_id: int, context: CouchersContext, include_deleted: bool = False 

216) -> tuple[Event, EventOccurrence] | None: 

217 result = session.execute( 

218 _get_event_and_occurrence_query(occurrence_id, include_deleted, context=context) 

219 ).one_or_none() 

220 return result._tuple() if result else None 

221 

222 

223def _check_occurrence_time_validity(start_time: datetime, end_time: datetime, context: CouchersContext) -> None: 

224 if start_time < now(): 

225 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_in_past") 

226 if end_time < start_time: 

227 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_ends_before_starts") 

228 if end_time - start_time > timedelta(days=7): 

229 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_too_long") 

230 if start_time - now() > timedelta(days=365): 

231 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_too_far_in_future") 

232 

233 

234def get_users_to_notify_for_new_event(session: Session, occurrence: EventOccurrence) -> tuple[list[User], int | None]: 

235 """ 

236 Returns the users to notify, as well as the community id that is being notified (None if based on geo search) 

237 """ 

238 cluster = occurrence.event.parent_node.official_cluster 

239 if occurrence.event.parent_node.node_type.value <= NodeType.region.value: 

240 logger.info("Global, macroregion, and region communities are too big for email notifications.") 

241 return [], occurrence.event.parent_node_id 

242 elif occurrence.creator_user in cluster.admins or cluster.is_leaf: 242 ↛ 245line 242 didn't jump to line 245 because the condition on line 242 was always true

243 return list(cluster.members.where(User.is_visible)), occurrence.event.parent_node_id 

244 else: 

245 max_radius = 20000 # m 

246 users = ( 

247 session.execute( 

248 select(User) 

249 .join(ClusterSubscription, ClusterSubscription.user_id == User.id) 

250 .where(User.is_visible) 

251 .where(ClusterSubscription.cluster_id == cluster.id) 

252 .where(func.ST_DWithin(User.geom, occurrence.geom, max_radius / 111111)) 

253 ) 

254 .scalars() 

255 .all() 

256 ) 

257 return cast(tuple[list[User], int | None], (users, None)) 

258 

259 

260def generate_event_create_notifications(payload: jobs_pb2.GenerateEventCreateNotificationsPayload) -> None: 

261 """ 

262 Background job to generated/fan out event notifications 

263 """ 

264 # Import here to avoid circular dependency 

265 from couchers.servicers.communities import community_to_pb # noqa: PLC0415 

266 

267 logger.info(f"Fanning out notifications for event occurrence id = {payload.occurrence_id}") 

268 

269 with session_scope() as session: 

270 event, occurrence = _get_event_and_occurrence_one(session, occurrence_id=payload.occurrence_id) 

271 creator = occurrence.creator_user 

272 

273 users, node_id = get_users_to_notify_for_new_event(session, occurrence) 

274 

275 inviting_user = session.execute(select(User).where(User.id == payload.inviting_user_id)).scalar_one_or_none() 

276 

277 if not inviting_user: 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true

278 logger.error(f"Inviting user {payload.inviting_user_id} is gone while trying to send event notification?") 

279 return 

280 

281 for user in users: 

282 if is_not_visible(session, user.id, creator.id): 282 ↛ 283line 282 didn't jump to line 283 because the condition on line 282 was never true

283 continue 

284 context = make_notification_user_context(user_id=user.id) 

285 topic_action = ( 

286 NotificationTopicAction.event__create_approved 

287 if payload.approved 

288 else NotificationTopicAction.event__create_any 

289 ) 

290 notify( 

291 session, 

292 user_id=user.id, 

293 topic_action=topic_action, 

294 key=str(payload.occurrence_id), 

295 data=notification_data_pb2.EventCreate( 

296 event=event_to_pb(session, occurrence, context), 

297 inviting_user=user_model_to_pb(inviting_user, session, context), 

298 nearby=True if node_id is None else None, 

299 in_community=community_to_pb(session, event.parent_node, context) if node_id is not None else None, 

300 ), 

301 moderation_state_id=occurrence.moderation_state_id, 

302 ) 

303 

304 

305def generate_event_update_notifications(payload: jobs_pb2.GenerateEventUpdateNotificationsPayload) -> None: 

306 with session_scope() as session: 

307 event, occurrence = _get_event_and_occurrence_one(session, occurrence_id=payload.occurrence_id) 

308 

309 updating_user = session.execute(select(User).where(User.id == payload.updating_user_id)).scalar_one() 

310 

311 subscribed_user_ids = [user.id for user in event.subscribers] 

312 attending_user_ids = [user.user_id for user in occurrence.attendances] 

313 

314 for user_id in set(subscribed_user_ids + attending_user_ids): 

315 if is_not_visible(session, user_id, updating_user.id): 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true

316 continue 

317 context = make_notification_user_context(user_id=user_id) 

318 notify( 

319 session, 

320 user_id=user_id, 

321 topic_action=NotificationTopicAction.event__update, 

322 key=str(payload.occurrence_id), 

323 data=notification_data_pb2.EventUpdate( 

324 event=event_to_pb(session, occurrence, context), 

325 updating_user=user_model_to_pb(updating_user, session, context), 

326 # TODO(#9117): Remove update_str_items once known unused. 

327 updated_str_items=payload.updated_str_items, 

328 updated_enum_items=( 

329 notification_data_pb2.EventUpdateItem.ValueType(value) for value in payload.updated_enum_items 

330 ), 

331 ), 

332 moderation_state_id=occurrence.moderation_state_id, 

333 ) 

334 

335 

336def generate_event_cancel_notifications(payload: jobs_pb2.GenerateEventCancelNotificationsPayload) -> None: 

337 with session_scope() as session: 

338 event, occurrence = _get_event_and_occurrence_one(session, occurrence_id=payload.occurrence_id) 

339 

340 cancelling_user = session.execute(select(User).where(User.id == payload.cancelling_user_id)).scalar_one() 

341 

342 subscribed_user_ids = [user.id for user in event.subscribers] 

343 attending_user_ids = [user.user_id for user in occurrence.attendances] 

344 

345 for user_id in set(subscribed_user_ids + attending_user_ids): 

346 if is_not_visible(session, user_id, cancelling_user.id): 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true

347 continue 

348 context = make_notification_user_context(user_id=user_id) 

349 notify( 

350 session, 

351 user_id=user_id, 

352 topic_action=NotificationTopicAction.event__cancel, 

353 key=str(payload.occurrence_id), 

354 data=notification_data_pb2.EventCancel( 

355 event=event_to_pb(session, occurrence, context), 

356 cancelling_user=user_model_to_pb(cancelling_user, session, context), 

357 ), 

358 moderation_state_id=occurrence.moderation_state_id, 

359 ) 

360 

361 

362def generate_event_delete_notifications(payload: jobs_pb2.GenerateEventDeleteNotificationsPayload) -> None: 

363 with session_scope() as session: 

364 event, occurrence = _get_event_and_occurrence_one( 

365 session, occurrence_id=payload.occurrence_id, include_deleted=True 

366 ) 

367 

368 subscribed_user_ids = [user.id for user in event.subscribers] 

369 attending_user_ids = [user.user_id for user in occurrence.attendances] 

370 

371 for user_id in set(subscribed_user_ids + attending_user_ids): 

372 context = make_notification_user_context(user_id=user_id) 

373 notify( 

374 session, 

375 user_id=user_id, 

376 topic_action=NotificationTopicAction.event__delete, 

377 key=str(payload.occurrence_id), 

378 data=notification_data_pb2.EventDelete( 

379 event=event_to_pb(session, occurrence, context), 

380 ), 

381 moderation_state_id=occurrence.moderation_state_id, 

382 ) 

383 

384 

385class Events(events_pb2_grpc.EventsServicer): 

386 def CreateEvent( 

387 self, request: events_pb2.CreateEventReq, context: CouchersContext, session: Session 

388 ) -> events_pb2.Event: 

389 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

390 if not has_completed_profile(session, user): 

391 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_create_event") 

392 if not request.title: 

393 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_title") 

394 if not request.content: 

395 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_content") 

396 

397 # As protobuf parses a missing value as 0.0, this is not a permitted event coordinate value 

398 if not ( 

399 request.HasField("location") and request.location.address and request.location.lat and request.location.lng 

400 ): 

401 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_address_or_location") 

402 if request.location.lat == 0 and request.location.lng == 0: 402 ↛ 403line 402 didn't jump to line 403 because the condition on line 402 was never true

403 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate") 

404 geom = create_coordinate(request.location.lat, request.location.lng) 

405 address = request.location.address 

406 

407 start_time = to_aware_datetime(request.start_time) 

408 end_time = to_aware_datetime(request.end_time) 

409 

410 _check_occurrence_time_validity(start_time, end_time, context) 

411 

412 if request.parent_community_id: 

413 parent_node = session.execute( 

414 select(Node).where(Node.id == request.parent_community_id) 

415 ).scalar_one_or_none() 

416 

417 if not parent_node or not parent_node.official_cluster.small_community_features_enabled: 417 ↛ 418line 417 didn't jump to line 418 because the condition on line 417 was never true

418 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "events_not_enabled") 

419 else: 

420 # parent community computed from geom 

421 parent_node = get_parent_node_at_location(session, not_none(geom)) 

422 

423 if not parent_node: 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true

424 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "community_not_found") 

425 

426 if ( 

427 request.photo_key 

428 and not session.execute(select(Upload).where(Upload.key == request.photo_key)).scalar_one_or_none() 

429 ): 

430 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "photo_not_found") 

431 

432 thread = Thread() 

433 session.add(thread) 

434 session.flush() 

435 

436 event = Event( 

437 title=request.title, 

438 parent_node_id=parent_node.id, 

439 owner_user_id=context.user_id, 

440 thread_id=thread.id, 

441 creator_user_id=context.user_id, 

442 ) 

443 session.add(event) 

444 session.flush() 

445 

446 occurrence: EventOccurrence | None = None 

447 

448 def create_occurrence(moderation_state_id: int) -> int: 

449 nonlocal occurrence 

450 occurrence = EventOccurrence( 

451 event_id=event.id, 

452 content=request.content, 

453 geom=geom, 

454 address=address, 

455 photo_key=request.photo_key if request.photo_key != "" else None, 

456 # timezone=timezone, 

457 during=TimestamptzRange(start_time, end_time), 

458 creator_user_id=context.user_id, 

459 moderation_state_id=moderation_state_id, 

460 ) 

461 session.add(occurrence) 

462 session.flush() 

463 return occurrence.id 

464 

465 create_moderation( 

466 session=session, 

467 object_type=ModerationObjectType.event_occurrence, 

468 object_id=create_occurrence, 

469 creator_user_id=context.user_id, 

470 ) 

471 

472 assert occurrence is not None 

473 

474 session.add( 

475 EventOrganizer( 

476 user_id=context.user_id, 

477 event_id=event.id, 

478 ) 

479 ) 

480 

481 session.add( 

482 EventSubscription( 

483 user_id=context.user_id, 

484 event_id=event.id, 

485 ) 

486 ) 

487 

488 session.add( 

489 EventOccurrenceAttendee( 

490 user_id=context.user_id, 

491 occurrence_id=occurrence.id, 

492 attendee_status=AttendeeStatus.going, 

493 ) 

494 ) 

495 

496 session.commit() 

497 

498 log_event( 

499 context, 

500 session, 

501 "event.created", 

502 { 

503 "event_id": event.id, 

504 "occurrence_id": occurrence.id, 

505 "parent_community_id": parent_node.id, 

506 "parent_community_name": parent_node.official_cluster.name, 

507 }, 

508 ) 

509 

510 if has_completed_profile(session, user): 510 ↛ 521line 510 didn't jump to line 521 because the condition on line 510 was always true

511 queue_job( 

512 session, 

513 job=generate_event_create_notifications, 

514 payload=jobs_pb2.GenerateEventCreateNotificationsPayload( 

515 inviting_user_id=user.id, 

516 occurrence_id=occurrence.id, 

517 approved=False, 

518 ), 

519 ) 

520 

521 return event_to_pb(session, occurrence, context) 

522 

523 def ScheduleEvent( 

524 self, request: events_pb2.ScheduleEventReq, context: CouchersContext, session: Session 

525 ) -> events_pb2.Event: 

526 if not request.content: 526 ↛ 527line 526 didn't jump to line 527 because the condition on line 526 was never true

527 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_content") 

528 if not ( 528 ↛ 531line 528 didn't jump to line 531 because the condition on line 528 was never true

529 request.HasField("location") and request.location.address and request.location.lat and request.location.lng 

530 ): 

531 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_address_or_location") 

532 if request.location.lat == 0 and request.location.lng == 0: 532 ↛ 533line 532 didn't jump to line 533 because the condition on line 532 was never true

533 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate") 

534 geom = create_coordinate(request.location.lat, request.location.lng) 

535 address = request.location.address 

536 

537 start_time = to_aware_datetime(request.start_time) 

538 end_time = to_aware_datetime(request.end_time) 

539 

540 _check_occurrence_time_validity(start_time, end_time, context) 

541 

542 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context) 

543 if not res: 543 ↛ 544line 543 didn't jump to line 544 because the condition on line 543 was never true

544 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found") 

545 

546 event, occurrence = res 

547 

548 if not _can_edit_event(session, event, context.user_id): 548 ↛ 549line 548 didn't jump to line 549 because the condition on line 548 was never true

549 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied") 

550 

551 if occurrence.is_cancelled: 551 ↛ 552line 551 didn't jump to line 552 because the condition on line 551 was never true

552 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event") 

553 

554 if ( 554 ↛ 558line 554 didn't jump to line 558 because the condition on line 554 was never true

555 request.photo_key 

556 and not session.execute(select(Upload).where(Upload.key == request.photo_key)).scalar_one_or_none() 

557 ): 

558 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "photo_not_found") 

559 

560 during = TimestamptzRange(start_time, end_time) 

561 

562 # && is the overlap operator for ranges 

563 if ( 

564 session.execute( 

565 select(EventOccurrence.id) 

566 .where(EventOccurrence.event_id == event.id) 

567 .where(EventOccurrence.during.op("&&")(during)) 

568 .limit(1) 

569 ) 

570 .scalars() 

571 .one_or_none() 

572 is not None 

573 ): 

574 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_overlap") 

575 

576 new_occurrence: EventOccurrence | None = None 

577 

578 def create_occurrence(moderation_state_id: int) -> int: 

579 nonlocal new_occurrence 

580 new_occurrence = EventOccurrence( 

581 event_id=event.id, 

582 content=request.content, 

583 geom=geom, 

584 address=address, 

585 photo_key=request.photo_key if request.photo_key != "" else None, 

586 # timezone=timezone, 

587 during=during, 

588 creator_user_id=context.user_id, 

589 moderation_state_id=moderation_state_id, 

590 ) 

591 session.add(new_occurrence) 

592 session.flush() 

593 return new_occurrence.id 

594 

595 create_moderation( 

596 session=session, 

597 object_type=ModerationObjectType.event_occurrence, 

598 object_id=create_occurrence, 

599 creator_user_id=context.user_id, 

600 ) 

601 

602 assert new_occurrence is not None 

603 

604 session.add( 

605 EventOccurrenceAttendee( 

606 user_id=context.user_id, 

607 occurrence_id=new_occurrence.id, 

608 attendee_status=AttendeeStatus.going, 

609 ) 

610 ) 

611 

612 session.flush() 

613 

614 # TODO: notify 

615 

616 return event_to_pb(session, new_occurrence, context) 

617 

618 def UpdateEvent( 

619 self, request: events_pb2.UpdateEventReq, context: CouchersContext, session: Session 

620 ) -> events_pb2.Event: 

621 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

622 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context) 

623 if not res: 623 ↛ 624line 623 didn't jump to line 624 because the condition on line 623 was never true

624 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found") 

625 

626 event, occurrence = res 

627 

628 if not _can_edit_event(session, event, context.user_id): 628 ↛ 629line 628 didn't jump to line 629 because the condition on line 628 was never true

629 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied") 

630 

631 # the things that were updated and need to be notified about 

632 notify_updated: list[notification_data_pb2.EventUpdateItem.ValueType] = [] 

633 

634 if occurrence.is_cancelled: 

635 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event") 

636 

637 occurrence_update: dict[str, Any] = {"last_edited": now()} 

638 

639 if request.HasField("title"): 

640 notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_TITLE) 

641 event.title = request.title.value 

642 

643 if request.HasField("content"): 

644 notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_CONTENT) 

645 occurrence_update["content"] = request.content.value 

646 

647 if request.HasField("photo_key"): 647 ↛ 648line 647 didn't jump to line 648 because the condition on line 647 was never true

648 occurrence_update["photo_key"] = request.photo_key.value 

649 

650 if request.HasField("location"): 

651 notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION) 

652 if request.location.lat == 0 and request.location.lng == 0: 652 ↛ 653line 652 didn't jump to line 653 because the condition on line 652 was never true

653 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate") 

654 occurrence_update["geom"] = create_coordinate(request.location.lat, request.location.lng) 

655 occurrence_update["address"] = request.location.address 

656 

657 if request.HasField("start_time") or request.HasField("end_time"): 

658 if request.update_all_future: 658 ↛ 659line 658 didn't jump to line 659 because the condition on line 658 was never true

659 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_cant_update_all_times") 

660 if request.HasField("start_time"): 660 ↛ 664line 660 didn't jump to line 664 because the condition on line 660 was always true

661 notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME) 

662 start_time = to_aware_datetime(request.start_time) 

663 else: 

664 start_time = occurrence.start_time 

665 if request.HasField("end_time"): 

666 notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME) 

667 end_time = to_aware_datetime(request.end_time) 

668 else: 

669 end_time = occurrence.end_time 

670 

671 _check_occurrence_time_validity(start_time, end_time, context) 

672 

673 during = TimestamptzRange(start_time, end_time) 

674 

675 # && is the overlap operator for ranges 

676 if ( 

677 session.execute( 

678 select(EventOccurrence.id) 

679 .where(EventOccurrence.event_id == event.id) 

680 .where(EventOccurrence.id != occurrence.id) 

681 .where(EventOccurrence.during.op("&&")(during)) 

682 .limit(1) 

683 ) 

684 .scalars() 

685 .one_or_none() 

686 is not None 

687 ): 

688 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_overlap") 

689 

690 occurrence_update["during"] = during 

691 

692 # TODO 

693 # if request.HasField("timezone"): 

694 # occurrence_update["timezone"] = request.timezone 

695 

696 # allow editing any event which hasn't ended more than 24 hours before now 

697 # when editing all future events, we edit all which have not yet ended 

698 

699 cutoff_time = now() - timedelta(hours=24) 

700 if request.update_all_future: 

701 session.execute( 

702 update(EventOccurrence) 

703 .where(EventOccurrence.end_time >= cutoff_time) 

704 .where(EventOccurrence.start_time >= occurrence.start_time) 

705 .values(occurrence_update) 

706 .execution_options(synchronize_session=False) 

707 ) 

708 else: 

709 if occurrence.end_time < cutoff_time: 709 ↛ 710line 709 didn't jump to line 710 because the condition on line 709 was never true

710 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event") 

711 session.execute( 

712 update(EventOccurrence) 

713 .where(EventOccurrence.end_time >= cutoff_time) 

714 .where(EventOccurrence.id == occurrence.id) 

715 .values(occurrence_update) 

716 .execution_options(synchronize_session=False) 

717 ) 

718 

719 session.flush() 

720 

721 if notify_updated: 

722 items_str = ",".join(notification_data_pb2.EventUpdateItem.Name(item) for item in notify_updated) 

723 if request.should_notify: 

724 logger.info(f"Items {items_str} updated in event {event.id=}, notifying") 

725 

726 queue_job( 

727 session, 

728 job=generate_event_update_notifications, 

729 payload=jobs_pb2.GenerateEventUpdateNotificationsPayload( 

730 updating_user_id=user.id, 

731 occurrence_id=occurrence.id, 

732 updated_enum_items=notify_updated, 

733 ), 

734 ) 

735 else: 

736 logger.info(f"Items {items_str} updated in event {event.id=}, but skipping notifications") 

737 

738 # since we have synchronize_session=False, we have to refresh the object 

739 session.refresh(occurrence) 

740 

741 return event_to_pb(session, occurrence, context) 

742 

743 def GetEvent(self, request: events_pb2.GetEventReq, context: CouchersContext, session: Session) -> events_pb2.Event: 

744 query = select(EventOccurrence).where(EventOccurrence.id == request.event_id).where(~EventOccurrence.is_deleted) 

745 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=False) 

746 occurrence = session.execute(query).scalar_one_or_none() 

747 

748 if not occurrence: 

749 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found") 

750 

751 return event_to_pb(session, occurrence, context) 

752 

753 def CancelEvent( 

754 self, request: events_pb2.CancelEventReq, context: CouchersContext, session: Session 

755 ) -> empty_pb2.Empty: 

756 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context) 

757 if not res: 757 ↛ 758line 757 didn't jump to line 758 because the condition on line 757 was never true

758 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found") 

759 

760 event, occurrence = res 

761 

762 if not _can_edit_event(session, event, context.user_id): 762 ↛ 763line 762 didn't jump to line 763 because the condition on line 762 was never true

763 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied") 

764 

765 if occurrence.end_time < now() - timedelta(hours=24): 765 ↛ 766line 765 didn't jump to line 766 because the condition on line 765 was never true

766 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_cancel_old_event") 

767 

768 occurrence.is_cancelled = True 

769 

770 log_event(context, session, "event.cancelled", {"event_id": event.id, "occurrence_id": occurrence.id}) 

771 

772 queue_job( 

773 session, 

774 job=generate_event_cancel_notifications, 

775 payload=jobs_pb2.GenerateEventCancelNotificationsPayload( 

776 cancelling_user_id=context.user_id, 

777 occurrence_id=occurrence.id, 

778 ), 

779 ) 

780 

781 return empty_pb2.Empty() 

782 

783 def RequestCommunityInvite( 

784 self, request: events_pb2.RequestCommunityInviteReq, context: CouchersContext, session: Session 

785 ) -> empty_pb2.Empty: 

786 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

787 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context) 

788 if not res: 788 ↛ 789line 788 didn't jump to line 789 because the condition on line 788 was never true

789 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found") 

790 

791 event, occurrence = res 

792 

793 if not _can_edit_event(session, event, context.user_id): 

794 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied") 

795 

796 if occurrence.is_cancelled: 796 ↛ 797line 796 didn't jump to line 797 because the condition on line 796 was never true

797 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event") 

798 

799 if occurrence.end_time < now() - timedelta(hours=24): 799 ↛ 800line 799 didn't jump to line 800 because the condition on line 799 was never true

800 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event") 

801 

802 this_user_reqs = [req for req in occurrence.community_invite_requests if req.user_id == context.user_id] 

803 

804 if len(this_user_reqs) > 0: 

805 context.abort_with_error_code( 

806 grpc.StatusCode.FAILED_PRECONDITION, "event_community_invite_already_requested" 

807 ) 

808 

809 approved_reqs = [req for req in occurrence.community_invite_requests if req.approved] 

810 

811 if len(approved_reqs) > 0: 

812 context.abort_with_error_code( 

813 grpc.StatusCode.FAILED_PRECONDITION, "event_community_invite_already_approved" 

814 ) 

815 

816 req = EventCommunityInviteRequest( 

817 occurrence_id=request.event_id, 

818 user_id=context.user_id, 

819 ) 

820 session.add(req) 

821 session.flush() 

822 

823 send_event_community_invite_request_email(session, req) 

824 

825 return empty_pb2.Empty() 

826 

827 def ListEventOccurrences( 

828 self, request: events_pb2.ListEventOccurrencesReq, context: CouchersContext, session: Session 

829 ) -> events_pb2.ListEventOccurrencesRes: 

830 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

831 # the page token is a unix timestamp of where we left off 

832 page_token = dt_from_millis(int(request.page_token)) if request.page_token else now() 

833 initial_query = ( 

834 select(EventOccurrence).where(EventOccurrence.id == request.event_id).where(~EventOccurrence.is_deleted) 

835 ) 

836 initial_query = where_moderated_content_visible( 

837 initial_query, context, EventOccurrence, is_list_operation=False 

838 ) 

839 occurrence = session.execute(initial_query).scalar_one_or_none() 

840 if not occurrence: 840 ↛ 841line 840 didn't jump to line 841 because the condition on line 840 was never true

841 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found") 

842 

843 query = ( 

844 select(EventOccurrence) 

845 .where(EventOccurrence.event_id == occurrence.event_id) 

846 .where(~EventOccurrence.is_deleted) 

847 ) 

848 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=True) 

849 

850 if not request.include_cancelled: 

851 query = query.where(~EventOccurrence.is_cancelled) 

852 

853 if not request.past: 853 ↛ 857line 853 didn't jump to line 857 because the condition on line 853 was always true

854 cutoff = page_token - timedelta(seconds=1) 

855 query = query.where(EventOccurrence.end_time > cutoff).order_by(EventOccurrence.start_time.asc()) 

856 else: 

857 cutoff = page_token + timedelta(seconds=1) 

858 query = query.where(EventOccurrence.end_time < cutoff).order_by(EventOccurrence.start_time.desc()) 

859 

860 query = query.limit(page_size + 1) 

861 occurrences = session.execute(query).scalars().all() 

862 

863 return events_pb2.ListEventOccurrencesRes( 

864 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]], 

865 next_page_token=str(millis_from_dt(occurrences[-1].end_time)) if len(occurrences) > page_size else None, 

866 ) 

867 

868 def ListEventAttendees( 

869 self, request: events_pb2.ListEventAttendeesReq, context: CouchersContext, session: Session 

870 ) -> events_pb2.ListEventAttendeesRes: 

871 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

872 next_user_id = int(request.page_token) if request.page_token else 0 

873 occurrence = session.execute( 

874 where_moderated_content_visible( 

875 select(EventOccurrence) 

876 .where(EventOccurrence.id == request.event_id) 

877 .where(~EventOccurrence.is_deleted), 

878 context, 

879 EventOccurrence, 

880 is_list_operation=False, 

881 ) 

882 ).scalar_one_or_none() 

883 if not occurrence: 883 ↛ 884line 883 didn't jump to line 884 because the condition on line 883 was never true

884 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found") 

885 attendees = ( 

886 session.execute( 

887 where_users_column_visible( 

888 select(EventOccurrenceAttendee) 

889 .where(EventOccurrenceAttendee.occurrence_id == occurrence.id) 

890 .where(EventOccurrenceAttendee.user_id >= next_user_id) 

891 .order_by(EventOccurrenceAttendee.user_id) 

892 .limit(page_size + 1), 

893 context, 

894 EventOccurrenceAttendee.user_id, 

895 ) 

896 ) 

897 .scalars() 

898 .all() 

899 ) 

900 return events_pb2.ListEventAttendeesRes( 

901 attendee_user_ids=[attendee.user_id for attendee in attendees[:page_size]], 

902 next_page_token=str(attendees[-1].user_id) if len(attendees) > page_size else None, 

903 ) 

904 

905 def ListEventSubscribers( 

906 self, request: events_pb2.ListEventSubscribersReq, context: CouchersContext, session: Session 

907 ) -> events_pb2.ListEventSubscribersRes: 

908 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

909 next_user_id = int(request.page_token) if request.page_token else 0 

910 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context) 

911 if not res: 911 ↛ 912line 911 didn't jump to line 912 because the condition on line 911 was never true

912 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found") 

913 event, occurrence = res 

914 subscribers = ( 

915 session.execute( 

916 where_users_column_visible( 

917 select(EventSubscription) 

918 .where(EventSubscription.event_id == event.id) 

919 .where(EventSubscription.user_id >= next_user_id) 

920 .order_by(EventSubscription.user_id) 

921 .limit(page_size + 1), 

922 context, 

923 EventSubscription.user_id, 

924 ) 

925 ) 

926 .scalars() 

927 .all() 

928 ) 

929 return events_pb2.ListEventSubscribersRes( 

930 subscriber_user_ids=[subscriber.user_id for subscriber in subscribers[:page_size]], 

931 next_page_token=str(subscribers[-1].user_id) if len(subscribers) > page_size else None, 

932 ) 

933 

934 def ListEventOrganizers( 

935 self, request: events_pb2.ListEventOrganizersReq, context: CouchersContext, session: Session 

936 ) -> events_pb2.ListEventOrganizersRes: 

937 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

938 next_user_id = int(request.page_token) if request.page_token else 0 

939 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context) 

940 if not res: 940 ↛ 941line 940 didn't jump to line 941 because the condition on line 940 was never true

941 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found") 

942 event, occurrence = res 

943 organizers = ( 

944 session.execute( 

945 where_users_column_visible( 

946 select(EventOrganizer) 

947 .where(EventOrganizer.event_id == event.id) 

948 .where(EventOrganizer.user_id >= next_user_id) 

949 .order_by(EventOrganizer.user_id) 

950 .limit(page_size + 1), 

951 context, 

952 EventOrganizer.user_id, 

953 ) 

954 ) 

955 .scalars() 

956 .all() 

957 ) 

958 return events_pb2.ListEventOrganizersRes( 

959 organizer_user_ids=[organizer.user_id for organizer in organizers[:page_size]], 

960 next_page_token=str(organizers[-1].user_id) if len(organizers) > page_size else None, 

961 ) 

962 

963 def TransferEvent( 

964 self, request: events_pb2.TransferEventReq, context: CouchersContext, session: Session 

965 ) -> events_pb2.Event: 

966 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context) 

967 if not res: 967 ↛ 968line 967 didn't jump to line 968 because the condition on line 967 was never true

968 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found") 

969 

970 event, occurrence = res 

971 

972 if not _can_edit_event(session, event, context.user_id): 

973 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_transfer_permission_denied") 

974 

975 if occurrence.is_cancelled: 

976 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event") 

977 

978 if occurrence.end_time < now() - timedelta(hours=24): 978 ↛ 979line 978 didn't jump to line 979 because the condition on line 978 was never true

979 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event") 

980 

981 if request.WhichOneof("new_owner") == "new_owner_group_id": 

982 cluster = session.execute( 

983 select(Cluster).where(~Cluster.is_official_cluster).where(Cluster.id == request.new_owner_group_id) 

984 ).scalar_one_or_none() 

985 elif request.WhichOneof("new_owner") == "new_owner_community_id": 985 ↛ 992line 985 didn't jump to line 992 because the condition on line 985 was always true

986 cluster = session.execute( 

987 select(Cluster) 

988 .where(Cluster.parent_node_id == request.new_owner_community_id) 

989 .where(Cluster.is_official_cluster) 

990 ).scalar_one_or_none() 

991 

992 if not cluster: 992 ↛ 993line 992 didn't jump to line 993 because the condition on line 992 was never true

993 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "group_or_community_not_found") 

994 

995 event.owner_user = None 

996 event.owner_cluster = cluster 

997 

998 session.commit() 

999 return event_to_pb(session, occurrence, context) 

1000 

1001 def SetEventSubscription( 

1002 self, request: events_pb2.SetEventSubscriptionReq, context: CouchersContext, session: Session 

1003 ) -> events_pb2.Event: 

1004 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context) 

1005 if not res: 1005 ↛ 1006line 1005 didn't jump to line 1006 because the condition on line 1005 was never true

1006 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found") 

1007 

1008 event, occurrence = res 

1009 

1010 if occurrence.is_cancelled: 

1011 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event") 

1012 

1013 if occurrence.end_time < now() - timedelta(hours=24): 1013 ↛ 1014line 1013 didn't jump to line 1014 because the condition on line 1013 was never true

1014 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event") 

1015 

1016 current_subscription = session.execute( 

1017 select(EventSubscription) 

1018 .where(EventSubscription.user_id == context.user_id) 

1019 .where(EventSubscription.event_id == event.id) 

1020 ).scalar_one_or_none() 

1021 

1022 # if not subscribed, subscribe 

1023 if request.subscribe and not current_subscription: 

1024 session.add(EventSubscription(user_id=context.user_id, event_id=event.id)) 

1025 

1026 # if subscribed but unsubbing, remove subscription 

1027 if not request.subscribe and current_subscription: 

1028 session.delete(current_subscription) 

1029 

1030 session.flush() 

1031 

1032 log_event( 

1033 context, 

1034 session, 

1035 "event.subscription_set", 

1036 {"event_id": event.id, "occurrence_id": occurrence.id, "subscribed": request.subscribe}, 

1037 ) 

1038 

1039 return event_to_pb(session, occurrence, context) 

1040 

1041 def SetEventAttendance( 

1042 self, request: events_pb2.SetEventAttendanceReq, context: CouchersContext, session: Session 

1043 ) -> events_pb2.Event: 

1044 occurrence = session.execute( 

1045 where_moderated_content_visible( 

1046 select(EventOccurrence) 

1047 .where(EventOccurrence.id == request.event_id) 

1048 .where(~EventOccurrence.is_deleted), 

1049 context, 

1050 EventOccurrence, 

1051 is_list_operation=False, 

1052 ) 

1053 ).scalar_one_or_none() 

1054 

1055 if not occurrence: 1055 ↛ 1056line 1055 didn't jump to line 1056 because the condition on line 1055 was never true

1056 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found") 

1057 

1058 if occurrence.is_cancelled: 

1059 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event") 

1060 

1061 if occurrence.end_time < now() - timedelta(hours=24): 1061 ↛ 1062line 1061 didn't jump to line 1062 because the condition on line 1061 was never true

1062 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event") 

1063 

1064 current_attendance = session.execute( 

1065 select(EventOccurrenceAttendee) 

1066 .where(EventOccurrenceAttendee.user_id == context.user_id) 

1067 .where(EventOccurrenceAttendee.occurrence_id == occurrence.id) 

1068 ).scalar_one_or_none() 

1069 

1070 if request.attendance_state == events_pb2.ATTENDANCE_STATE_NOT_GOING: 

1071 if current_attendance: 1071 ↛ 1086line 1071 didn't jump to line 1086 because the condition on line 1071 was always true

1072 session.delete(current_attendance) 

1073 # if unset/not going, nothing to do! 

1074 else: 

1075 if current_attendance: 1075 ↛ 1076line 1075 didn't jump to line 1076 because the condition on line 1075 was never true

1076 current_attendance.attendee_status = attendancestate2sql[request.attendance_state] # type: ignore[assignment] 

1077 else: 

1078 # create new 

1079 attendance = EventOccurrenceAttendee( 

1080 user_id=context.user_id, 

1081 occurrence_id=occurrence.id, 

1082 attendee_status=not_none(attendancestate2sql[request.attendance_state]), 

1083 ) 

1084 session.add(attendance) 

1085 

1086 session.flush() 

1087 

1088 log_event( 

1089 context, 

1090 session, 

1091 "event.attendance_set", 

1092 {"occurrence_id": occurrence.id, "attendance_state": request.attendance_state}, 

1093 ) 

1094 

1095 return event_to_pb(session, occurrence, context) 

1096 

1097 def ListMyEvents( 

1098 self, request: events_pb2.ListMyEventsReq, context: CouchersContext, session: Session 

1099 ) -> events_pb2.ListMyEventsRes: 

1100 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

1101 # the page token is a unix timestamp of where we left off 

1102 page_token = ( 

1103 dt_from_millis(int(request.page_token)) if request.page_token and not request.page_number else now() 

1104 ) 

1105 # the page number is the page number we are on 

1106 page_number = request.page_number or 1 

1107 # Calculate the offset for pagination 

1108 offset = (page_number - 1) * page_size 

1109 query = ( 

1110 select(EventOccurrence).join(Event, Event.id == EventOccurrence.event_id).where(~EventOccurrence.is_deleted) 

1111 ) 

1112 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=True) 

1113 

1114 include_all = not (request.subscribed or request.attending or request.organizing or request.my_communities) 

1115 include_subscribed = request.subscribed or include_all 

1116 include_organizing = request.organizing or include_all 

1117 include_attending = request.attending or include_all 

1118 include_my_communities = request.my_communities or include_all 

1119 

1120 if include_attending and request.exclude_attending: 

1121 context.abort_with_error_code( 

1122 grpc.StatusCode.INVALID_ARGUMENT, "cannot_combine_attending_and_exclude_attending" 

1123 ) 

1124 

1125 where_ = [] 

1126 

1127 if include_subscribed: 

1128 query = query.outerjoin( 

1129 EventSubscription, 

1130 and_(EventSubscription.event_id == Event.id, EventSubscription.user_id == context.user_id), 

1131 ) 

1132 where_.append(EventSubscription.user_id != None) 

1133 if include_organizing: 

1134 query = query.outerjoin( 

1135 EventOrganizer, and_(EventOrganizer.event_id == Event.id, EventOrganizer.user_id == context.user_id) 

1136 ) 

1137 where_.append(EventOrganizer.user_id != None) 

1138 if include_attending or request.exclude_attending: 

1139 query = query.outerjoin( 

1140 EventOccurrenceAttendee, 

1141 and_( 

1142 EventOccurrenceAttendee.occurrence_id == EventOccurrence.id, 

1143 EventOccurrenceAttendee.user_id == context.user_id, 

1144 ), 

1145 ) 

1146 if include_attending: 

1147 where_.append(EventOccurrenceAttendee.user_id != None) 

1148 elif request.exclude_attending: 1148 ↛ 1155line 1148 didn't jump to line 1155 because the condition on line 1148 was always true

1149 if not include_organizing: 1149 ↛ 1154line 1149 didn't jump to line 1154 because the condition on line 1149 was always true

1150 query = query.outerjoin( 

1151 EventOrganizer, 

1152 and_(EventOrganizer.event_id == Event.id, EventOrganizer.user_id == context.user_id), 

1153 ) 

1154 query = query.where(EventOccurrenceAttendee.user_id == None, EventOrganizer.user_id == None) 

1155 if include_my_communities: 

1156 my_communities = ( 

1157 session.execute( 

1158 select(Node.id) 

1159 .join(Cluster, Cluster.parent_node_id == Node.id) 

1160 .join(ClusterSubscription, ClusterSubscription.cluster_id == Cluster.id) 

1161 .where(ClusterSubscription.user_id == context.user_id) 

1162 .where(Cluster.is_official_cluster) 

1163 .order_by(Node.id) 

1164 .limit(100000) 

1165 ) 

1166 .scalars() 

1167 .all() 

1168 ) 

1169 where_.append(Event.parent_node_id.in_(my_communities)) 

1170 

1171 query = query.where(or_(*where_)) 

1172 

1173 if request.my_communities_exclude_global: 

1174 query = query.join(Node, Node.id == Event.parent_node_id).where(Node.node_type > NodeType.region) 

1175 

1176 if not request.include_cancelled: 

1177 query = query.where(~EventOccurrence.is_cancelled) 

1178 

1179 if not request.past: 1179 ↛ 1183line 1179 didn't jump to line 1183 because the condition on line 1179 was always true

1180 cutoff = page_token - timedelta(seconds=1) 

1181 query = query.where(EventOccurrence.end_time > cutoff).order_by(EventOccurrence.start_time.asc()) 

1182 else: 

1183 cutoff = page_token + timedelta(seconds=1) 

1184 query = query.where(EventOccurrence.end_time < cutoff).order_by(EventOccurrence.start_time.desc()) 

1185 # Count the total number of items for pagination 

1186 total_items = session.execute(select(func.count()).select_from(query.subquery())).scalar() 

1187 # Apply pagination by page number 

1188 query = query.offset(offset).limit(page_size) if request.page_number else query.limit(page_size + 1) 

1189 occurrences = session.execute(query).scalars().all() 

1190 

1191 return events_pb2.ListMyEventsRes( 

1192 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]], 

1193 next_page_token=str(millis_from_dt(occurrences[-1].end_time)) if len(occurrences) > page_size else None, 

1194 total_items=total_items, 

1195 ) 

1196 

1197 def ListAllEvents( 

1198 self, request: events_pb2.ListAllEventsReq, context: CouchersContext, session: Session 

1199 ) -> events_pb2.ListAllEventsRes: 

1200 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

1201 # the page token is a unix timestamp of where we left off 

1202 page_token = dt_from_millis(int(request.page_token)) if request.page_token else now() 

1203 

1204 query = select(EventOccurrence).where(~EventOccurrence.is_deleted) 

1205 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=True) 

1206 

1207 if not request.include_cancelled: 1207 ↛ 1210line 1207 didn't jump to line 1210 because the condition on line 1207 was always true

1208 query = query.where(~EventOccurrence.is_cancelled) 

1209 

1210 if not request.past: 

1211 cutoff = page_token - timedelta(seconds=1) 

1212 query = query.where(EventOccurrence.end_time > cutoff).order_by(EventOccurrence.start_time.asc()) 

1213 else: 

1214 cutoff = page_token + timedelta(seconds=1) 

1215 query = query.where(EventOccurrence.end_time < cutoff).order_by(EventOccurrence.start_time.desc()) 

1216 

1217 query = query.limit(page_size + 1) 

1218 occurrences = session.execute(query).scalars().all() 

1219 

1220 return events_pb2.ListAllEventsRes( 

1221 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]], 

1222 next_page_token=str(millis_from_dt(occurrences[-1].end_time)) if len(occurrences) > page_size else None, 

1223 ) 

1224 

1225 def InviteEventOrganizer( 

1226 self, request: events_pb2.InviteEventOrganizerReq, context: CouchersContext, session: Session 

1227 ) -> empty_pb2.Empty: 

1228 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

1229 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context) 

1230 if not res: 1230 ↛ 1231line 1230 didn't jump to line 1231 because the condition on line 1230 was never true

1231 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found") 

1232 

1233 event, occurrence = res 

1234 

1235 if not _can_edit_event(session, event, context.user_id): 

1236 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied") 

1237 

1238 if occurrence.is_cancelled: 

1239 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event") 

1240 

1241 if occurrence.end_time < now() - timedelta(hours=24): 1241 ↛ 1242line 1241 didn't jump to line 1242 because the condition on line 1241 was never true

1242 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event") 

1243 

1244 if not session.execute( 1244 ↛ 1247line 1244 didn't jump to line 1247 because the condition on line 1244 was never true

1245 select(User).where(users_visible(context)).where(User.id == request.user_id) 

1246 ).scalar_one_or_none(): 

1247 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found") 

1248 

1249 session.add( 

1250 EventOrganizer( 

1251 user_id=request.user_id, 

1252 event_id=event.id, 

1253 ) 

1254 ) 

1255 session.flush() 

1256 

1257 other_user_context = make_notification_user_context(user_id=request.user_id) 

1258 

1259 notify( 

1260 session, 

1261 user_id=request.user_id, 

1262 topic_action=NotificationTopicAction.event__invite_organizer, 

1263 key=str(event.id), 

1264 data=notification_data_pb2.EventInviteOrganizer( 

1265 event=event_to_pb(session, occurrence, other_user_context), 

1266 inviting_user=user_model_to_pb(user, session, other_user_context), 

1267 ), 

1268 ) 

1269 

1270 return empty_pb2.Empty() 

1271 

1272 def RemoveEventOrganizer( 

1273 self, request: events_pb2.RemoveEventOrganizerReq, context: CouchersContext, session: Session 

1274 ) -> empty_pb2.Empty: 

1275 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context) 

1276 if not res: 1276 ↛ 1277line 1276 didn't jump to line 1277 because the condition on line 1276 was never true

1277 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found") 

1278 

1279 event, occurrence = res 

1280 

1281 if occurrence.is_cancelled: 1281 ↛ 1282line 1281 didn't jump to line 1282 because the condition on line 1281 was never true

1282 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event") 

1283 

1284 if occurrence.end_time < now() - timedelta(hours=24): 1284 ↛ 1285line 1284 didn't jump to line 1285 because the condition on line 1284 was never true

1285 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event") 

1286 

1287 # Determine which user to remove 

1288 user_id_to_remove = request.user_id.value if request.HasField("user_id") else context.user_id 

1289 

1290 # Check if the target user is the event owner (only after permission check) 

1291 if event.owner_user_id == user_id_to_remove: 

1292 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_remove_owner_as_organizer") 

1293 

1294 # Check permissions: either an organizer removing an organizer OR you're the event owner 

1295 if not _can_edit_event(session, event, context.user_id): 

1296 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_edit_permission_denied") 

1297 

1298 # Find the organizer to remove 

1299 organizer_to_remove = session.execute( 

1300 select(EventOrganizer) 

1301 .where(EventOrganizer.user_id == user_id_to_remove) 

1302 .where(EventOrganizer.event_id == event.id) 

1303 ).scalar_one_or_none() 

1304 

1305 if not organizer_to_remove: 1305 ↛ 1306line 1305 didn't jump to line 1306 because the condition on line 1305 was never true

1306 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_not_an_organizer") 

1307 

1308 session.delete(organizer_to_remove) 

1309 

1310 return empty_pb2.Empty()