Coverage for src/couchers/servicers/galleries.py: 96%
107 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-20 11:53 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-20 11:53 +0000
1import grpc
2from sqlalchemy.orm import Session
3from sqlalchemy.sql import func
5from couchers.constants import GALLERY_MAX_PHOTOS_NOT_VERIFIED, GALLERY_MAX_PHOTOS_VERIFIED
6from couchers.helpers.strong_verification import has_strong_verification
7from couchers.models import PhotoGallery, PhotoGalleryItem, Upload, User
8from couchers.proto import galleries_pb2, galleries_pb2_grpc
9from couchers.sql import couchers_select as select
12def _get_max_photos_for_user(session: Session, user: User) -> int:
13 return GALLERY_MAX_PHOTOS_VERIFIED if has_strong_verification(session, user) else GALLERY_MAX_PHOTOS_NOT_VERIFIED
16def _can_edit_gallery(gallery: PhotoGallery, context) -> bool:
17 return gallery.owner_user_id == context.user_id
20def _gallery_to_pb(gallery: PhotoGallery, context) -> galleries_pb2.PhotoGallery:
21 return galleries_pb2.PhotoGallery(
22 gallery_id=gallery.id,
23 photos=[
24 galleries_pb2.PhotoGalleryItem(
25 item_id=item.id,
26 full_url=item.upload.full_url,
27 thumbnail_url=item.upload.thumbnail_url,
28 caption=item.caption,
29 )
30 for item in gallery.photos # Already ordered by position via relationship
31 ],
32 can_edit=_can_edit_gallery(gallery, context),
33 )
36class Galleries(galleries_pb2_grpc.GalleriesServicer):
37 def GetGallery(self, request: galleries_pb2.GetGalleryReq, context, session: Session) -> galleries_pb2.PhotoGallery:
38 gallery = session.execute(
39 select(PhotoGallery).where(PhotoGallery.id == request.gallery_id)
40 ).scalar_one_or_none()
42 if not gallery:
43 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_not_found")
45 return _gallery_to_pb(gallery, context)
47 def AddPhotoToGallery(
48 self, request: galleries_pb2.AddPhotoToGalleryReq, context, session: Session
49 ) -> galleries_pb2.PhotoGallery:
50 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
52 gallery = session.execute(
53 select(PhotoGallery).where(PhotoGallery.id == request.gallery_id)
54 ).scalar_one_or_none()
56 if not gallery:
57 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_not_found")
59 if not _can_edit_gallery(gallery, context):
60 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "permission_denied_to_edit_gallery")
62 upload_exists = session.execute(
63 select(Upload.key).where(Upload.key == request.upload_key).where(Upload.creator_user_id == user.id)
64 ).scalar_one_or_none()
66 if not upload_exists:
67 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "upload_not_found_or_not_owned")
69 # Get all gallery items' upload keys and positions in one query
70 gallery_items = session.execute(
71 select(PhotoGalleryItem.upload_key, PhotoGalleryItem.position).where(
72 PhotoGalleryItem.gallery_id == gallery.id
73 )
74 ).all()
76 existing_upload_keys = {item.upload_key for item in gallery_items}
78 if request.upload_key in existing_upload_keys:
79 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "photo_already_in_gallery")
81 max_photos = _get_max_photos_for_user(session, user)
83 if len(gallery_items) >= max_photos:
84 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "gallery_at_max_capacity")
86 # Get max position and add 1.0 (or start at 0.0 if empty)
87 max_position = max((item.position for item in gallery_items), default=None)
89 item = PhotoGalleryItem(
90 gallery_id=gallery.id,
91 upload_key=request.upload_key,
92 position=0.0 if max_position is None else max_position + 1.0,
93 caption=request.caption or None,
94 )
95 session.add(item)
96 session.flush()
97 session.refresh(gallery)
99 return _gallery_to_pb(gallery, context)
101 def RemovePhotoFromGallery(
102 self, request: galleries_pb2.RemovePhotoFromGalleryReq, context, session: Session
103 ) -> galleries_pb2.PhotoGallery:
104 gallery = session.execute(
105 select(PhotoGallery).where(PhotoGallery.id == request.gallery_id)
106 ).scalar_one_or_none()
108 if not gallery:
109 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_not_found")
111 if not _can_edit_gallery(gallery, context):
112 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "permission_denied_to_edit_gallery")
114 item = session.execute(
115 select(PhotoGalleryItem)
116 .where(PhotoGalleryItem.gallery_id == gallery.id)
117 .where(PhotoGalleryItem.id == request.item_id)
118 ).scalar_one_or_none()
120 if not item:
121 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_item_not_found")
123 session.delete(item)
124 session.flush()
125 session.refresh(gallery)
127 return _gallery_to_pb(gallery, context)
129 def MovePhoto(self, request: galleries_pb2.MovePhotoReq, context, session: Session) -> galleries_pb2.PhotoGallery:
130 gallery = session.execute(
131 select(PhotoGallery).where(PhotoGallery.id == request.gallery_id)
132 ).scalar_one_or_none()
134 if not gallery:
135 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_not_found")
137 if not _can_edit_gallery(gallery, context):
138 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "permission_denied_to_edit_gallery")
140 # Get all items' (id, position) sorted by position
141 items = session.execute(
142 select(PhotoGalleryItem.id, PhotoGalleryItem.position)
143 .where(PhotoGalleryItem.gallery_id == gallery.id)
144 .order_by(PhotoGalleryItem.position)
145 ).all()
147 positions = {item.id: item.position for item in items}
148 sorted_ids = [item.id for item in items]
150 if request.item_id not in positions:
151 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_item_not_found")
153 if request.after_item_id == request.item_id:
154 # Moving after itself is a no-op
155 return _gallery_to_pb(gallery, context)
157 if request.after_item_id == 0:
158 # Move to first position
159 new_position = positions[sorted_ids[0]] - 1.0 if sorted_ids[0] != request.item_id else None
160 else:
161 if request.after_item_id not in positions:
162 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "after_item_not_found")
164 after_idx = sorted_ids.index(request.after_item_id)
165 next_idx = after_idx + 1
167 # Skip over the item being moved if it's the next one
168 if next_idx < len(sorted_ids) and sorted_ids[next_idx] == request.item_id:
169 next_idx += 1
171 if next_idx >= len(sorted_ids):
172 # Place at end
173 new_position = positions[request.after_item_id] + 1.0
174 else:
175 # Place between after_item and next_item
176 new_position = (positions[request.after_item_id] + positions[sorted_ids[next_idx]]) / 2.0
178 if new_position is not None:
179 session.execute(
180 PhotoGalleryItem.__table__.update()
181 .where(PhotoGalleryItem.id == request.item_id)
182 .values(position=new_position)
183 )
185 session.flush()
186 session.refresh(gallery)
188 return _gallery_to_pb(gallery, context)
190 def UpdatePhotoCaption(
191 self, request: galleries_pb2.UpdatePhotoCaptionReq, context, session: Session
192 ) -> galleries_pb2.PhotoGallery:
193 gallery = session.execute(
194 select(PhotoGallery).where(PhotoGallery.id == request.gallery_id)
195 ).scalar_one_or_none()
197 if not gallery:
198 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_not_found")
200 if not _can_edit_gallery(gallery, context):
201 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "permission_denied_to_edit_gallery")
203 item = session.execute(
204 select(PhotoGalleryItem)
205 .where(PhotoGalleryItem.gallery_id == gallery.id)
206 .where(PhotoGalleryItem.id == request.item_id)
207 ).scalar_one_or_none()
209 if not item:
210 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_item_not_found")
212 item.caption = request.caption or None
213 session.flush()
214 session.refresh(gallery)
216 return _gallery_to_pb(gallery, context)
218 def GetGalleryEditInfo(
219 self, request: galleries_pb2.GetGalleryEditInfoReq, context, session: Session
220 ) -> galleries_pb2.GetGalleryEditInfoRes:
221 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
223 gallery = session.execute(
224 select(PhotoGallery).where(PhotoGallery.id == request.gallery_id)
225 ).scalar_one_or_none()
227 if not gallery:
228 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "gallery_not_found")
230 if not _can_edit_gallery(gallery, context):
231 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "permission_denied_to_edit_gallery")
233 current_photo_count = session.execute(
234 select(func.count()).where(PhotoGalleryItem.gallery_id == gallery.id)
235 ).scalar_one()
237 return galleries_pb2.GetGalleryEditInfoRes(
238 gallery_id=gallery.id,
239 max_photos=_get_max_photos_for_user(session, user),
240 current_photo_count=current_photo_count,
241 )