Coverage for app/backend/src/couchers/servicers/galleries.py: 94%
112 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
1import grpc
2from sqlalchemy import select, update
3from sqlalchemy.orm import Session
4from sqlalchemy.sql import func
6from couchers.constants import GALLERY_MAX_PHOTOS_NOT_VERIFIED, GALLERY_MAX_PHOTOS_VERIFIED
7from couchers.context import CouchersContext
8from couchers.helpers.strong_verification import has_strong_verification
9from couchers.models import PhotoGallery, PhotoGalleryItem, Upload, User
10from couchers.proto import galleries_pb2, galleries_pb2_grpc
13def _get_max_photos_for_user(session: Session, user: User) -> int:
14 return GALLERY_MAX_PHOTOS_VERIFIED if has_strong_verification(session, user) else GALLERY_MAX_PHOTOS_NOT_VERIFIED
17def _can_edit_gallery(gallery: PhotoGallery, context: CouchersContext) -> bool:
18 return gallery.owner_user_id == context.user_id
21def _gallery_to_pb(gallery: PhotoGallery, context: CouchersContext) -> galleries_pb2.PhotoGallery:
22 return galleries_pb2.PhotoGallery(
23 gallery_id=gallery.id,
24 photos=[
25 galleries_pb2.PhotoGalleryItem(
26 item_id=item.id,
27 full_url=item.upload.full_url,
28 thumbnail_url=item.upload.thumbnail_url,
29 caption=item.caption,
30 )
31 for item in gallery.photos # Already ordered by position via relationship
32 ],
33 can_edit=_can_edit_gallery(gallery, context),
34 )
37class Galleries(galleries_pb2_grpc.GalleriesServicer):
38 def GetGallery(
39 self, request: galleries_pb2.GetGalleryReq, context: CouchersContext, session: Session
40 ) -> galleries_pb2.PhotoGallery:
41 gallery = session.execute(
42 select(PhotoGallery).where(PhotoGallery.id == request.gallery_id)
43 ).scalar_one_or_none()
45 if not gallery:
46 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_not_found")
48 return _gallery_to_pb(gallery, context)
50 def AddPhotoToGallery(
51 self, request: galleries_pb2.AddPhotoToGalleryReq, context: CouchersContext, session: Session
52 ) -> galleries_pb2.PhotoGallery:
53 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
55 gallery = session.execute(
56 select(PhotoGallery).where(PhotoGallery.id == request.gallery_id)
57 ).scalar_one_or_none()
59 if not gallery:
60 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_not_found")
62 if not _can_edit_gallery(gallery, context):
63 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "permission_denied_to_edit_gallery")
65 upload_exists = session.execute(
66 select(Upload.key).where(Upload.key == request.upload_key).where(Upload.creator_user_id == user.id)
67 ).scalar_one_or_none()
69 if not upload_exists:
70 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "upload_not_found_or_not_owned")
72 # Get all gallery items' upload keys and positions in one query
73 gallery_items = session.execute(
74 select(PhotoGalleryItem.upload_key, PhotoGalleryItem.position).where(
75 PhotoGalleryItem.gallery_id == gallery.id
76 )
77 ).all()
79 existing_upload_keys = {item.upload_key for item in gallery_items}
81 if request.upload_key in existing_upload_keys:
82 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "photo_already_in_gallery")
84 max_photos = _get_max_photos_for_user(session, user)
86 if len(gallery_items) >= max_photos:
87 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "gallery_at_max_capacity")
89 # Get max position and add 1.0 (or start at 0.0 if empty)
90 max_position = max((item.position for item in gallery_items), default=None)
92 item = PhotoGalleryItem(
93 gallery_id=gallery.id,
94 upload_key=request.upload_key,
95 position=0.0 if max_position is None else max_position + 1.0,
96 caption=request.caption or None,
97 )
98 session.add(item)
99 gallery.last_updated = func.now()
100 session.flush()
101 session.refresh(gallery)
103 return _gallery_to_pb(gallery, context)
105 def RemovePhotoFromGallery(
106 self, request: galleries_pb2.RemovePhotoFromGalleryReq, context: CouchersContext, session: Session
107 ) -> galleries_pb2.PhotoGallery:
108 gallery = session.execute(
109 select(PhotoGallery).where(PhotoGallery.id == request.gallery_id)
110 ).scalar_one_or_none()
112 if not gallery: 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true
113 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_not_found")
115 if not _can_edit_gallery(gallery, context):
116 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "permission_denied_to_edit_gallery")
118 item = session.execute(
119 select(PhotoGalleryItem)
120 .where(PhotoGalleryItem.gallery_id == gallery.id)
121 .where(PhotoGalleryItem.id == request.item_id)
122 ).scalar_one_or_none()
124 if not item:
125 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_item_not_found")
127 session.delete(item)
128 gallery.last_updated = func.now()
129 session.flush()
130 session.refresh(gallery)
132 return _gallery_to_pb(gallery, context)
134 def MovePhoto(
135 self, request: galleries_pb2.MovePhotoReq, context: CouchersContext, session: Session
136 ) -> galleries_pb2.PhotoGallery:
137 gallery = session.execute(
138 select(PhotoGallery).where(PhotoGallery.id == request.gallery_id)
139 ).scalar_one_or_none()
141 if not gallery: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
142 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_not_found")
144 if not _can_edit_gallery(gallery, context):
145 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "permission_denied_to_edit_gallery")
147 # Get all items' (id, position) sorted by position
148 items = session.execute(
149 select(PhotoGalleryItem.id, PhotoGalleryItem.position)
150 .where(PhotoGalleryItem.gallery_id == gallery.id)
151 .order_by(PhotoGalleryItem.position)
152 ).all()
154 positions = {item.id: item.position for item in items}
155 sorted_ids = [item.id for item in items]
157 if request.item_id not in positions:
158 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_item_not_found")
160 if request.after_item_id == request.item_id:
161 # Moving after itself is a no-op
162 return _gallery_to_pb(gallery, context)
164 if request.after_item_id == 0:
165 # Move to first position
166 new_position = positions[sorted_ids[0]] - 1.0 if sorted_ids[0] != request.item_id else None
167 else:
168 if request.after_item_id not in positions:
169 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "after_item_not_found")
171 after_idx = sorted_ids.index(request.after_item_id)
172 next_idx = after_idx + 1
174 # Skip over the item being moved if it's the next one
175 if next_idx < len(sorted_ids) and sorted_ids[next_idx] == request.item_id: 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true
176 next_idx += 1
178 if next_idx >= len(sorted_ids):
179 # Place at end
180 new_position = positions[request.after_item_id] + 1.0
181 else:
182 # Place between after_item and next_item
183 new_position = (positions[request.after_item_id] + positions[sorted_ids[next_idx]]) / 2.0
185 if new_position is not None: 185 ↛ 191line 185 didn't jump to line 191 because the condition on line 185 was always true
186 session.execute(
187 update(PhotoGalleryItem).where(PhotoGalleryItem.id == request.item_id).values(position=new_position)
188 )
189 gallery.last_updated = func.now()
191 session.flush()
192 session.refresh(gallery)
194 return _gallery_to_pb(gallery, context)
196 def UpdatePhotoCaption(
197 self, request: galleries_pb2.UpdatePhotoCaptionReq, context: CouchersContext, session: Session
198 ) -> galleries_pb2.PhotoGallery:
199 gallery = session.execute(
200 select(PhotoGallery).where(PhotoGallery.id == request.gallery_id)
201 ).scalar_one_or_none()
203 if not gallery: 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true
204 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_not_found")
206 if not _can_edit_gallery(gallery, context):
207 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "permission_denied_to_edit_gallery")
209 item = session.execute(
210 select(PhotoGalleryItem)
211 .where(PhotoGalleryItem.gallery_id == gallery.id)
212 .where(PhotoGalleryItem.id == request.item_id)
213 ).scalar_one_or_none()
215 if not item:
216 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_item_not_found")
218 item.caption = request.caption or None
219 gallery.last_updated = func.now()
220 session.flush()
221 session.refresh(gallery)
223 return _gallery_to_pb(gallery, context)
225 def GetGalleryEditInfo(
226 self, request: galleries_pb2.GetGalleryEditInfoReq, context: CouchersContext, session: Session
227 ) -> galleries_pb2.GetGalleryEditInfoRes:
228 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
230 gallery = session.execute(
231 select(PhotoGallery).where(PhotoGallery.id == request.gallery_id)
232 ).scalar_one_or_none()
234 if not gallery:
235 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_not_found")
237 if not _can_edit_gallery(gallery, context):
238 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "permission_denied_to_edit_gallery")
240 current_photo_count = session.execute(
241 select(func.count()).where(PhotoGalleryItem.gallery_id == gallery.id)
242 ).scalar_one()
244 return galleries_pb2.GetGalleryEditInfoRes(
245 gallery_id=gallery.id,
246 max_photos=_get_max_photos_for_user(session, user),
247 current_photo_count=current_photo_count,
248 )