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

546 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 09:44 +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_background_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 events_pb2.AttendanceState.ATTENDANCE_STATE_MAYBE: AttendeeStatus.maybe, 

60} 

61 

62attendancestate2api = { 

63 None: events_pb2.AttendanceState.ATTENDANCE_STATE_NOT_GOING, 

64 AttendeeStatus.going: events_pb2.AttendanceState.ATTENDANCE_STATE_GOING, 

65 AttendeeStatus.maybe: events_pb2.AttendanceState.ATTENDANCE_STATE_MAYBE, 

66} 

67 

68MAX_PAGINATION_LENGTH = 25 

69 

70 

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

72 """ 

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

74 """ 

75 if event.owner_user: 

76 return event.owner_user_id == user_id 

77 # otherwise owned by a cluster 

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

79 

80 

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

82 """ 

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

84 """ 

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

86 

87 

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

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

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

91 return True 

92 

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

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

95 

96 

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

98 return ( 

99 _is_event_owner(event, user_id) 

100 or _is_event_organizer(event, user_id) 

101 or _can_moderate_event(session, event, user_id) 

102 ) 

103 

104 

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

106 event = occurrence.event 

107 

108 next_occurrence = ( 

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

110 .order_by(EventOccurrence.end_time.asc()) 

111 .limit(1) 

112 .one_or_none() 

113 ) 

114 

115 owner_community_id = None 

116 owner_group_id = None 

117 if event.owner_cluster: 

118 if event.owner_cluster.is_official_cluster: 

119 owner_community_id = event.owner_cluster.parent_node_id 

120 else: 

121 owner_group_id = event.owner_cluster.id 

122 

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

124 attendance_state = attendance.attendee_status if attendance else None 

125 

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

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

128 

129 going_count = session.execute( 

130 where_users_column_visible( 

131 select(func.count()) 

132 .select_from(EventOccurrenceAttendee) 

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

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

135 context, 

136 EventOccurrenceAttendee.user_id, 

137 ) 

138 ).scalar_one() 

139 maybe_count = session.execute( 

140 where_users_column_visible( 

141 select(func.count()) 

142 .select_from(EventOccurrenceAttendee) 

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

144 .where(EventOccurrenceAttendee.attendee_status == AttendeeStatus.maybe), 

145 context, 

146 EventOccurrenceAttendee.user_id, 

147 ) 

148 ).scalar_one() 

149 

150 organizer_count = session.execute( 

151 where_users_column_visible( 

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

153 context, 

154 EventOrganizer.user_id, 

155 ) 

156 ).scalar_one() 

157 subscriber_count = session.execute( 

158 where_users_column_visible( 

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

160 context, 

161 EventSubscription.user_id, 

162 ) 

163 ).scalar_one() 

164 

165 return events_pb2.Event( 

166 event_id=occurrence.id, 

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

168 is_cancelled=occurrence.is_cancelled, 

169 is_deleted=occurrence.is_deleted, 

170 title=event.title, 

171 slug=event.slug, 

172 content=occurrence.content, 

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

174 photo_key=occurrence.photo_key or "", 

175 online_information=( 

176 events_pb2.OnlineEventInformation( 

177 link=occurrence.link, 

178 ) 

179 if occurrence.link 

180 else None 

181 ), 

182 offline_information=( 

183 events_pb2.OfflineEventInformation( 

184 lat=not_none(occurrence.coordinates)[0], 

185 lng=not_none(occurrence.coordinates)[1], 

186 address=occurrence.address, 

187 ) 

188 if occurrence.geom 

189 else None 

190 ), 

191 created=Timestamp_from_datetime(occurrence.created), 

192 last_edited=Timestamp_from_datetime(occurrence.last_edited), 

193 creator_user_id=occurrence.creator_user_id, 

194 start_time=Timestamp_from_datetime(occurrence.start_time), 

195 end_time=Timestamp_from_datetime(occurrence.end_time), 

196 timezone=occurrence.timezone, 

197 start_time_display=str(occurrence.start_time), 

198 end_time_display=str(occurrence.end_time), 

199 attendance_state=attendancestate2api[attendance_state], 

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

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

202 going_count=going_count, 

203 maybe_count=maybe_count, 

204 organizer_count=organizer_count, 

205 subscriber_count=subscriber_count, 

206 owner_user_id=event.owner_user_id, 

207 owner_community_id=owner_community_id, 

208 owner_group_id=owner_group_id, 

209 thread=thread_to_pb(session, event.thread_id), 

210 can_edit=can_edit, 

211 can_moderate=can_moderate, 

212 ) 

213 

214 

215def _get_event_and_occurrence_query( 

216 occurrence_id: int, 

217 include_deleted: bool, 

218 context: CouchersContext | None = None, 

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

220 query = ( 

221 select(Event, EventOccurrence) 

222 .where(EventOccurrence.id == occurrence_id) 

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

224 ) 

225 

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

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

228 

229 if context is not None: 

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

231 

232 return query 

233 

234 

235def _get_event_and_occurrence_one( 

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

237) -> tuple[Event, EventOccurrence]: 

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

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

240 return result._tuple() 

241 

242 

243def _get_event_and_occurrence_one_or_none( 

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

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

246 result = session.execute( 

247 _get_event_and_occurrence_query(occurrence_id, include_deleted, context=context) 

248 ).one_or_none() 

249 return result._tuple() if result else None 

250 

251 

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

253 if start_time < now(): 

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

255 if end_time < start_time: 

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

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

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

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

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

261 

262 

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

264 """ 

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

266 """ 

267 cluster = occurrence.event.parent_node.official_cluster 

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

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

270 return [], occurrence.event.parent_node_id 

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

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

273 else: 

274 max_radius = 20000 # m 

275 users = ( 

276 session.execute( 

277 select(User) 

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

279 .where(User.is_visible) 

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

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

282 ) 

283 .scalars() 

284 .all() 

285 ) 

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

287 

288 

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

290 """ 

291 Background job to generated/fan out event notifications 

292 """ 

293 # Import here to avoid circular dependency 

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

295 

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

297 

298 with session_scope() as session: 

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

300 creator = occurrence.creator_user 

301 

302 users, node_id = get_users_to_notify_for_new_event(session, occurrence) 

303 

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

305 

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

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

308 return 

309 

310 for user in users: 

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

312 continue 

313 context = make_background_user_context(user_id=user.id) 

314 topic_action = ( 

315 NotificationTopicAction.event__create_approved 

316 if payload.approved 

317 else NotificationTopicAction.event__create_any 

318 ) 

319 notify( 

320 session, 

321 user_id=user.id, 

322 topic_action=topic_action, 

323 key=str(payload.occurrence_id), 

324 data=notification_data_pb2.EventCreate( 

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

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

327 nearby=True if node_id is None else None, 

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

329 ), 

330 moderation_state_id=occurrence.moderation_state_id, 

331 ) 

332 

333 

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

335 with session_scope() as session: 

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

337 

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

339 

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

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

342 

343 for user_id in set(subscribed_user_ids + attending_user_ids): 

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

345 continue 

346 context = make_background_user_context(user_id=user_id) 

347 notify( 

348 session, 

349 user_id=user_id, 

350 topic_action=NotificationTopicAction.event__update, 

351 key=str(payload.occurrence_id), 

352 data=notification_data_pb2.EventUpdate( 

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

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

355 updated_items=payload.updated_items, 

356 ), 

357 moderation_state_id=occurrence.moderation_state_id, 

358 ) 

359 

360 

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

362 with session_scope() as session: 

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

364 

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

366 

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

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

369 

370 for user_id in set(subscribed_user_ids + attending_user_ids): 

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

372 continue 

373 context = make_background_user_context(user_id=user_id) 

374 notify( 

375 session, 

376 user_id=user_id, 

377 topic_action=NotificationTopicAction.event__cancel, 

378 key=str(payload.occurrence_id), 

379 data=notification_data_pb2.EventCancel( 

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

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

382 ), 

383 moderation_state_id=occurrence.moderation_state_id, 

384 ) 

385 

386 

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

388 with session_scope() as session: 

389 event, occurrence = _get_event_and_occurrence_one( 

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

391 ) 

392 

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

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

395 

396 for user_id in set(subscribed_user_ids + attending_user_ids): 

397 context = make_background_user_context(user_id=user_id) 

398 notify( 

399 session, 

400 user_id=user_id, 

401 topic_action=NotificationTopicAction.event__delete, 

402 key=str(payload.occurrence_id), 

403 data=notification_data_pb2.EventDelete( 

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

405 ), 

406 moderation_state_id=occurrence.moderation_state_id, 

407 ) 

408 

409 

410class Events(events_pb2_grpc.EventsServicer): 

411 def CreateEvent( 

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

413 ) -> events_pb2.Event: 

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

415 if not has_completed_profile(session, user): 

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

417 if not request.title: 

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

419 if not request.content: 

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

421 if request.HasField("online_information"): 

422 online = True 

423 geom = None 

424 address = None 

425 if not request.online_information.link: 

426 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "online_event_requires_link") 

427 link = request.online_information.link 

428 elif request.HasField("offline_information"): 428 ↛ 443line 428 didn't jump to line 443 because the condition on line 428 was always true

429 online = False 

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

431 if not ( 

432 request.offline_information.address 

433 and request.offline_information.lat 

434 and request.offline_information.lng 

435 ): 

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

437 if request.offline_information.lat == 0 and request.offline_information.lng == 0: 437 ↛ 438line 437 didn't jump to line 438 because the condition on line 437 was never true

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

439 geom = create_coordinate(request.offline_information.lat, request.offline_information.lng) 

440 address = request.offline_information.address 

441 link = None 

442 else: 

443 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_address_location_or_link") 

444 

445 start_time = to_aware_datetime(request.start_time) 

446 end_time = to_aware_datetime(request.end_time) 

447 

448 _check_occurrence_time_validity(start_time, end_time, context) 

449 

450 if request.parent_community_id: 

451 parent_node = session.execute( 

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

453 ).scalar_one_or_none() 

454 

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

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

457 else: 

458 if online: 

459 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "online_event_missing_parent_community") 

460 # parent community computed from geom 

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

462 

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

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

465 

466 if ( 

467 request.photo_key 

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

469 ): 

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

471 

472 thread = Thread() 

473 session.add(thread) 

474 session.flush() 

475 

476 event = Event( 

477 title=request.title, 

478 parent_node_id=parent_node.id, 

479 owner_user_id=context.user_id, 

480 thread_id=thread.id, 

481 creator_user_id=context.user_id, 

482 ) 

483 session.add(event) 

484 session.flush() 

485 

486 occurrence: EventOccurrence | None = None 

487 

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

489 nonlocal occurrence 

490 occurrence = EventOccurrence( 

491 event_id=event.id, 

492 content=request.content, 

493 geom=geom, 

494 address=address, 

495 link=link, 

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

497 # timezone=timezone, 

498 during=TimestamptzRange(start_time, end_time), 

499 creator_user_id=context.user_id, 

500 moderation_state_id=moderation_state_id, 

501 ) 

502 session.add(occurrence) 

503 session.flush() 

504 return occurrence.id 

505 

506 create_moderation( 

507 session=session, 

508 object_type=ModerationObjectType.event_occurrence, 

509 object_id=create_occurrence, 

510 creator_user_id=context.user_id, 

511 ) 

512 

513 assert occurrence is not None 

514 

515 session.add( 

516 EventOrganizer( 

517 user_id=context.user_id, 

518 event_id=event.id, 

519 ) 

520 ) 

521 

522 session.add( 

523 EventSubscription( 

524 user_id=context.user_id, 

525 event_id=event.id, 

526 ) 

527 ) 

528 

529 session.add( 

530 EventOccurrenceAttendee( 

531 user_id=context.user_id, 

532 occurrence_id=occurrence.id, 

533 attendee_status=AttendeeStatus.going, 

534 ) 

535 ) 

536 

537 session.commit() 

538 

539 log_event( 

540 context, 

541 session, 

542 "event.created", 

543 { 

544 "event_id": event.id, 

545 "occurrence_id": occurrence.id, 

546 "parent_community_id": parent_node.id, 

547 "parent_community_name": parent_node.official_cluster.name, 

548 "online": online, 

549 }, 

550 ) 

551 

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

553 queue_job( 

554 session, 

555 job=generate_event_create_notifications, 

556 payload=jobs_pb2.GenerateEventCreateNotificationsPayload( 

557 inviting_user_id=user.id, 

558 occurrence_id=occurrence.id, 

559 approved=False, 

560 ), 

561 ) 

562 

563 return event_to_pb(session, occurrence, context) 

564 

565 def ScheduleEvent( 

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

567 ) -> events_pb2.Event: 

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

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

570 if request.HasField("online_information"): 

571 geom = None 

572 address = None 

573 link = request.online_information.link 

574 elif request.HasField("offline_information"): 574 ↛ 587line 574 didn't jump to line 587 because the condition on line 574 was always true

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

576 request.offline_information.address 

577 and request.offline_information.lat 

578 and request.offline_information.lng 

579 ): 

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

581 if request.offline_information.lat == 0 and request.offline_information.lng == 0: 581 ↛ 582line 581 didn't jump to line 582 because the condition on line 581 was never true

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

583 geom = create_coordinate(request.offline_information.lat, request.offline_information.lng) 

584 address = request.offline_information.address 

585 link = None 

586 else: 

587 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_address_location_or_link") 

588 

589 start_time = to_aware_datetime(request.start_time) 

590 end_time = to_aware_datetime(request.end_time) 

591 

592 _check_occurrence_time_validity(start_time, end_time, context) 

593 

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

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

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

597 

598 event, occurrence = res 

599 

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

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

602 

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

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

605 

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

607 request.photo_key 

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

609 ): 

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

611 

612 during = TimestamptzRange(start_time, end_time) 

613 

614 # && is the overlap operator for ranges 

615 if ( 

616 session.execute( 

617 select(EventOccurrence.id) 

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

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

620 .limit(1) 

621 ) 

622 .scalars() 

623 .one_or_none() 

624 is not None 

625 ): 

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

627 

628 new_occurrence: EventOccurrence | None = None 

629 

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

631 nonlocal new_occurrence 

632 new_occurrence = EventOccurrence( 

633 event_id=event.id, 

634 content=request.content, 

635 geom=geom, 

636 address=address, 

637 link=link, 

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

639 # timezone=timezone, 

640 during=during, 

641 creator_user_id=context.user_id, 

642 moderation_state_id=moderation_state_id, 

643 ) 

644 session.add(new_occurrence) 

645 session.flush() 

646 return new_occurrence.id 

647 

648 create_moderation( 

649 session=session, 

650 object_type=ModerationObjectType.event_occurrence, 

651 object_id=create_occurrence, 

652 creator_user_id=context.user_id, 

653 ) 

654 

655 assert new_occurrence is not None 

656 

657 session.add( 

658 EventOccurrenceAttendee( 

659 user_id=context.user_id, 

660 occurrence_id=new_occurrence.id, 

661 attendee_status=AttendeeStatus.going, 

662 ) 

663 ) 

664 

665 session.flush() 

666 

667 # TODO: notify 

668 

669 return event_to_pb(session, new_occurrence, context) 

670 

671 def UpdateEvent( 

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

673 ) -> events_pb2.Event: 

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

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

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

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

678 

679 event, occurrence = res 

680 

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

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

683 

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

685 notify_updated = [] 

686 

687 if occurrence.is_cancelled: 

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

689 

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

691 

692 if request.HasField("title"): 

693 notify_updated.append("title") 

694 event.title = request.title.value 

695 

696 if request.HasField("content"): 

697 notify_updated.append("content") 

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

699 

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

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

702 

703 if request.HasField("online_information"): 

704 notify_updated.append("location") 

705 if not request.online_information.link: 705 ↛ 706line 705 didn't jump to line 706 because the condition on line 705 was never true

706 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "online_event_requires_link") 

707 occurrence_update["link"] = request.online_information.link 

708 occurrence_update["geom"] = None 

709 occurrence_update["address"] = None 

710 elif request.HasField("offline_information"): 

711 notify_updated.append("location") 

712 occurrence_update["link"] = None 

713 if request.offline_information.lat == 0 and request.offline_information.lng == 0: 713 ↛ 714line 713 didn't jump to line 714 because the condition on line 713 was never true

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

715 occurrence_update["geom"] = create_coordinate( 

716 request.offline_information.lat, request.offline_information.lng 

717 ) 

718 occurrence_update["address"] = request.offline_information.address 

719 

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

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

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

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

724 notify_updated.append("start time") 

725 start_time = to_aware_datetime(request.start_time) 

726 else: 

727 start_time = occurrence.start_time 

728 if request.HasField("end_time"): 

729 notify_updated.append("end time") 

730 end_time = to_aware_datetime(request.end_time) 

731 else: 

732 end_time = occurrence.end_time 

733 

734 _check_occurrence_time_validity(start_time, end_time, context) 

735 

736 during = TimestamptzRange(start_time, end_time) 

737 

738 # && is the overlap operator for ranges 

739 if ( 

740 session.execute( 

741 select(EventOccurrence.id) 

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

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

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

745 .limit(1) 

746 ) 

747 .scalars() 

748 .one_or_none() 

749 is not None 

750 ): 

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

752 

753 occurrence_update["during"] = during 

754 

755 # TODO 

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

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

758 

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

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

761 

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

763 if request.update_all_future: 

764 session.execute( 

765 update(EventOccurrence) 

766 .where(EventOccurrence.end_time >= cutoff_time) 

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

768 .values(occurrence_update) 

769 .execution_options(synchronize_session=False) 

770 ) 

771 else: 

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

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

774 session.execute( 

775 update(EventOccurrence) 

776 .where(EventOccurrence.end_time >= cutoff_time) 

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

778 .values(occurrence_update) 

779 .execution_options(synchronize_session=False) 

780 ) 

781 

782 session.flush() 

783 

784 if notify_updated: 

785 if request.should_notify: 

786 logger.info(f"Fields {','.join(notify_updated)} updated in event {event.id=}, notifying") 

787 

788 queue_job( 

789 session, 

790 job=generate_event_update_notifications, 

791 payload=jobs_pb2.GenerateEventUpdateNotificationsPayload( 

792 updating_user_id=user.id, 

793 occurrence_id=occurrence.id, 

794 updated_items=notify_updated, 

795 ), 

796 ) 

797 else: 

798 logger.info( 

799 f"Fields {','.join(notify_updated)} updated in event {event.id=}, but skipping notifications" 

800 ) 

801 

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

803 session.refresh(occurrence) 

804 

805 return event_to_pb(session, occurrence, context) 

806 

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

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

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

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

811 

812 if not occurrence: 

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

814 

815 return event_to_pb(session, occurrence, context) 

816 

817 def CancelEvent( 

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

819 ) -> empty_pb2.Empty: 

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

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

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

823 

824 event, occurrence = res 

825 

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

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

828 

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

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

831 

832 occurrence.is_cancelled = True 

833 

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

835 

836 queue_job( 

837 session, 

838 job=generate_event_cancel_notifications, 

839 payload=jobs_pb2.GenerateEventCancelNotificationsPayload( 

840 cancelling_user_id=context.user_id, 

841 occurrence_id=occurrence.id, 

842 ), 

843 ) 

844 

845 return empty_pb2.Empty() 

846 

847 def RequestCommunityInvite( 

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

849 ) -> empty_pb2.Empty: 

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

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

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

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

854 

855 event, occurrence = res 

856 

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

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

859 

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

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

862 

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

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

865 

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

867 

868 if len(this_user_reqs) > 0: 

869 context.abort_with_error_code( 

870 grpc.StatusCode.FAILED_PRECONDITION, "event_community_invite_already_requested" 

871 ) 

872 

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

874 

875 if len(approved_reqs) > 0: 

876 context.abort_with_error_code( 

877 grpc.StatusCode.FAILED_PRECONDITION, "event_community_invite_already_approved" 

878 ) 

879 

880 req = EventCommunityInviteRequest( 

881 occurrence_id=request.event_id, 

882 user_id=context.user_id, 

883 ) 

884 session.add(req) 

885 session.flush() 

886 

887 send_event_community_invite_request_email(session, req) 

888 

889 return empty_pb2.Empty() 

890 

891 def ListEventOccurrences( 

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

893 ) -> events_pb2.ListEventOccurrencesRes: 

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

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

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

897 initial_query = ( 

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

899 ) 

900 initial_query = where_moderated_content_visible( 

901 initial_query, context, EventOccurrence, is_list_operation=False 

902 ) 

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

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

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

906 

907 query = ( 

908 select(EventOccurrence) 

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

910 .where(~EventOccurrence.is_deleted) 

911 ) 

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

913 

914 if not request.include_cancelled: 

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

916 

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

918 cutoff = page_token - timedelta(seconds=1) 

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

920 else: 

921 cutoff = page_token + timedelta(seconds=1) 

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

923 

924 query = query.limit(page_size + 1) 

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

926 

927 return events_pb2.ListEventOccurrencesRes( 

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

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

930 ) 

931 

932 def ListEventAttendees( 

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

934 ) -> events_pb2.ListEventAttendeesRes: 

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

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

937 occurrence = session.execute( 

938 where_moderated_content_visible( 

939 select(EventOccurrence) 

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

941 .where(~EventOccurrence.is_deleted), 

942 context, 

943 EventOccurrence, 

944 is_list_operation=False, 

945 ) 

946 ).scalar_one_or_none() 

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

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

949 attendees = ( 

950 session.execute( 

951 where_users_column_visible( 

952 select(EventOccurrenceAttendee) 

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

954 .where(EventOccurrenceAttendee.user_id >= next_user_id) 

955 .order_by(EventOccurrenceAttendee.user_id) 

956 .limit(page_size + 1), 

957 context, 

958 EventOccurrenceAttendee.user_id, 

959 ) 

960 ) 

961 .scalars() 

962 .all() 

963 ) 

964 return events_pb2.ListEventAttendeesRes( 

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

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

967 ) 

968 

969 def ListEventSubscribers( 

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

971 ) -> events_pb2.ListEventSubscribersRes: 

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

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

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

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

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

977 event, occurrence = res 

978 subscribers = ( 

979 session.execute( 

980 where_users_column_visible( 

981 select(EventSubscription) 

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

983 .where(EventSubscription.user_id >= next_user_id) 

984 .order_by(EventSubscription.user_id) 

985 .limit(page_size + 1), 

986 context, 

987 EventSubscription.user_id, 

988 ) 

989 ) 

990 .scalars() 

991 .all() 

992 ) 

993 return events_pb2.ListEventSubscribersRes( 

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

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

996 ) 

997 

998 def ListEventOrganizers( 

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

1000 ) -> events_pb2.ListEventOrganizersRes: 

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

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

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

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

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

1006 event, occurrence = res 

1007 organizers = ( 

1008 session.execute( 

1009 where_users_column_visible( 

1010 select(EventOrganizer) 

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

1012 .where(EventOrganizer.user_id >= next_user_id) 

1013 .order_by(EventOrganizer.user_id) 

1014 .limit(page_size + 1), 

1015 context, 

1016 EventOrganizer.user_id, 

1017 ) 

1018 ) 

1019 .scalars() 

1020 .all() 

1021 ) 

1022 return events_pb2.ListEventOrganizersRes( 

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

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

1025 ) 

1026 

1027 def TransferEvent( 

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

1029 ) -> events_pb2.Event: 

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

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

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

1033 

1034 event, occurrence = res 

1035 

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

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

1038 

1039 if occurrence.is_cancelled: 

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

1041 

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

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

1044 

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

1046 cluster = session.execute( 

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

1048 ).scalar_one_or_none() 

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

1050 cluster = session.execute( 

1051 select(Cluster) 

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

1053 .where(Cluster.is_official_cluster) 

1054 ).scalar_one_or_none() 

1055 

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

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

1058 

1059 event.owner_user = None 

1060 event.owner_cluster = cluster 

1061 

1062 session.commit() 

1063 return event_to_pb(session, occurrence, context) 

1064 

1065 def SetEventSubscription( 

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

1067 ) -> events_pb2.Event: 

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

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

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

1071 

1072 event, occurrence = res 

1073 

1074 if occurrence.is_cancelled: 

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

1076 

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

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

1079 

1080 current_subscription = session.execute( 

1081 select(EventSubscription) 

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

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

1084 ).scalar_one_or_none() 

1085 

1086 # if not subscribed, subscribe 

1087 if request.subscribe and not current_subscription: 

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

1089 

1090 # if subscribed but unsubbing, remove subscription 

1091 if not request.subscribe and current_subscription: 

1092 session.delete(current_subscription) 

1093 

1094 session.flush() 

1095 

1096 log_event( 

1097 context, 

1098 session, 

1099 "event.subscription_set", 

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

1101 ) 

1102 

1103 return event_to_pb(session, occurrence, context) 

1104 

1105 def SetEventAttendance( 

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

1107 ) -> events_pb2.Event: 

1108 occurrence = session.execute( 

1109 where_moderated_content_visible( 

1110 select(EventOccurrence) 

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

1112 .where(~EventOccurrence.is_deleted), 

1113 context, 

1114 EventOccurrence, 

1115 is_list_operation=False, 

1116 ) 

1117 ).scalar_one_or_none() 

1118 

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

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

1121 

1122 if occurrence.is_cancelled: 

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

1124 

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

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

1127 

1128 current_attendance = session.execute( 

1129 select(EventOccurrenceAttendee) 

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

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

1132 ).scalar_one_or_none() 

1133 

1134 if request.attendance_state == events_pb2.ATTENDANCE_STATE_NOT_GOING: 

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

1136 session.delete(current_attendance) 

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

1138 else: 

1139 if current_attendance: 

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

1141 else: 

1142 # create new 

1143 attendance = EventOccurrenceAttendee( 

1144 user_id=context.user_id, 

1145 occurrence_id=occurrence.id, 

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

1147 ) 

1148 session.add(attendance) 

1149 

1150 session.flush() 

1151 

1152 log_event( 

1153 context, 

1154 session, 

1155 "event.attendance_set", 

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

1157 ) 

1158 

1159 return event_to_pb(session, occurrence, context) 

1160 

1161 def ListMyEvents( 

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

1163 ) -> events_pb2.ListMyEventsRes: 

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

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

1166 page_token = ( 

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

1168 ) 

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

1170 page_number = request.page_number or 1 

1171 # Calculate the offset for pagination 

1172 offset = (page_number - 1) * page_size 

1173 query = ( 

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

1175 ) 

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

1177 

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

1179 include_subscribed = request.subscribed or include_all 

1180 include_organizing = request.organizing or include_all 

1181 include_attending = request.attending or include_all 

1182 include_my_communities = request.my_communities or include_all 

1183 

1184 where_ = [] 

1185 

1186 if include_subscribed: 

1187 query = query.outerjoin( 

1188 EventSubscription, 

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

1190 ) 

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

1192 if include_organizing: 

1193 query = query.outerjoin( 

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

1195 ) 

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

1197 if include_attending: 

1198 query = query.outerjoin( 

1199 EventOccurrenceAttendee, 

1200 and_( 

1201 EventOccurrenceAttendee.occurrence_id == EventOccurrence.id, 

1202 EventOccurrenceAttendee.user_id == context.user_id, 

1203 ), 

1204 ) 

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

1206 if include_my_communities: 

1207 my_communities = ( 

1208 session.execute( 

1209 select(Node.id) 

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

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

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

1213 .where(Cluster.is_official_cluster) 

1214 .order_by(Node.id) 

1215 .limit(100000) 

1216 ) 

1217 .scalars() 

1218 .all() 

1219 ) 

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

1221 

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

1223 

1224 if request.my_communities_exclude_global: 

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

1226 

1227 if not request.include_cancelled: 

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

1229 

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

1231 cutoff = page_token - timedelta(seconds=1) 

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

1233 else: 

1234 cutoff = page_token + timedelta(seconds=1) 

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

1236 # Count the total number of items for pagination 

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

1238 # Apply pagination by page number 

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

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

1241 

1242 return events_pb2.ListMyEventsRes( 

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

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

1245 total_items=total_items, 

1246 ) 

1247 

1248 def ListAllEvents( 

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

1250 ) -> events_pb2.ListAllEventsRes: 

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

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

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

1254 

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

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

1257 

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

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

1260 

1261 if not request.past: 

1262 cutoff = page_token - timedelta(seconds=1) 

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

1264 else: 

1265 cutoff = page_token + timedelta(seconds=1) 

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

1267 

1268 query = query.limit(page_size + 1) 

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

1270 

1271 return events_pb2.ListAllEventsRes( 

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

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

1274 ) 

1275 

1276 def InviteEventOrganizer( 

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

1278 ) -> empty_pb2.Empty: 

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

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

1281 if not res: 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.NOT_FOUND, "event_not_found") 

1283 

1284 event, occurrence = res 

1285 

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

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

1288 

1289 if occurrence.is_cancelled: 

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

1291 

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

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

1294 

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

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

1297 ).scalar_one_or_none(): 

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

1299 

1300 session.add( 

1301 EventOrganizer( 

1302 user_id=request.user_id, 

1303 event_id=event.id, 

1304 ) 

1305 ) 

1306 session.flush() 

1307 

1308 other_user_context = make_background_user_context(user_id=request.user_id) 

1309 

1310 notify( 

1311 session, 

1312 user_id=request.user_id, 

1313 topic_action=NotificationTopicAction.event__invite_organizer, 

1314 key=str(event.id), 

1315 data=notification_data_pb2.EventInviteOrganizer( 

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

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

1318 ), 

1319 ) 

1320 

1321 return empty_pb2.Empty() 

1322 

1323 def RemoveEventOrganizer( 

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

1325 ) -> empty_pb2.Empty: 

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

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

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

1329 

1330 event, occurrence = res 

1331 

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

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

1334 

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

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

1337 

1338 # Determine which user to remove 

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

1340 

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

1342 if event.owner_user_id == user_id_to_remove: 

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

1344 

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

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

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

1348 

1349 # Find the organizer to remove 

1350 organizer_to_remove = session.execute( 

1351 select(EventOrganizer) 

1352 .where(EventOrganizer.user_id == user_id_to_remove) 

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

1354 ).scalar_one_or_none() 

1355 

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

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

1358 

1359 session.delete(organizer_to_remove) 

1360 

1361 return empty_pb2.Empty()