Coverage for app/backend/src/couchers/models/uploads.py: 100%
60 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
1from datetime import datetime
2from typing import TYPE_CHECKING, Any
4from sqlalchemy import BigInteger, DateTime, Float, ForeignKey, String, UniqueConstraint, exists, func, literal, select
5from sqlalchemy.ext.hybrid import hybrid_property
6from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
7from sqlalchemy.sql.elements import ColumnElement
8from sqlalchemy.sql.selectable import Subquery
10from couchers import urls
11from couchers.models.base import Base
13if TYPE_CHECKING:
14 from couchers.models.users import User
17class InitiatedUpload(Base, kw_only=True):
18 """
19 Started downloads, not necessarily complete yet.
20 """
22 __tablename__ = "initiated_uploads"
24 key: Mapped[str] = mapped_column(String, primary_key=True)
26 # timezones should always be UTC
27 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
28 expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True))
30 initiator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
32 initiator_user: Mapped[User] = relationship(init=False)
34 @hybrid_property
35 def is_valid(self) -> Any:
36 return (self.created <= func.now()) & (self.expiry >= func.now())
39class Upload(Base, kw_only=True):
40 """
41 Completed uploads.
43 When adding a new foreign key to uploads.key, also update the reverse lookup in
44 couchers/helpers/upload_uses.py.
45 """
47 __tablename__ = "uploads"
49 key: Mapped[str] = mapped_column(String, primary_key=True)
51 filename: Mapped[str] = mapped_column(String)
52 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
53 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
55 # photo credit, etc
56 credit: Mapped[str | None] = mapped_column(String, default=None)
58 creator_user: Mapped[User] = relationship(init=False, backref="uploads", foreign_keys="Upload.creator_user_id")
60 def _url(self, size: str) -> str:
61 return urls.media_url(filename=self.filename, size=size)
63 @property
64 def thumbnail_url(self) -> str:
65 return self._url("thumbnail")
67 @property
68 def full_url(self) -> str:
69 return self._url("full")
72class PhotoGallery(Base, kw_only=True):
73 """
74 Photo galleries for users or other entities.
75 """
77 __tablename__ = "photo_galleries"
79 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
81 # For now, galleries are owned by users, but this could be extended
82 # in the future for communities, events, etc.
83 owner_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
85 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
86 last_updated: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
88 owner_user: Mapped[User] = relationship(init=False, foreign_keys=[owner_user_id], back_populates="galleries")
89 photos: Mapped[list[PhotoGalleryItem]] = relationship(
90 init=False,
91 back_populates="gallery",
92 order_by="PhotoGalleryItem.position",
93 )
96class PhotoGalleryItem(Base, kw_only=True):
97 """
98 Individual photos within a gallery with ordering and captions.
99 """
101 __tablename__ = "photo_gallery_items"
103 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
105 gallery_id: Mapped[int] = mapped_column(ForeignKey("photo_galleries.id"), index=True)
106 upload_key: Mapped[str] = mapped_column(ForeignKey("uploads.key"))
108 # Float position for ordering - allows inserting between items without shifting
109 position: Mapped[float] = mapped_column(Float)
111 caption: Mapped[str | None] = mapped_column(String, default=None)
113 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
115 gallery: Mapped[PhotoGallery] = relationship(init=False, back_populates="photos")
116 upload: Mapped[Upload] = relationship(init=False)
118 __table_args__ = (
119 # Ensure each upload is only in a gallery once
120 UniqueConstraint("gallery_id", "upload_key", name="uix_gallery_upload"),
121 )
124def get_avatar_photo_subquery(name: str = "avatar_photo") -> Subquery:
125 """
126 Returns a subquery that selects the first photo (by position) from each photo gallery.
128 The subquery has columns: gallery_id, upload_key
130 Usage:
131 avatar_photo = get_avatar_photo_subquery()
132 query = select(User).outerjoin(avatar_photo, avatar_photo.c.gallery_id == User.profile_gallery_id)
133 """
134 return (
135 select(
136 PhotoGalleryItem.gallery_id,
137 PhotoGalleryItem.upload_key,
138 )
139 .distinct(PhotoGalleryItem.gallery_id)
140 .order_by(PhotoGalleryItem.gallery_id, PhotoGalleryItem.position)
141 .subquery(name=name)
142 )
145def get_avatar_upload(session: Session, user: User) -> Upload | None:
146 """
147 Returns the Upload for the user's avatar (first photo in their profile gallery), or None.
148 """
149 return session.execute(
150 select(Upload)
151 .join(PhotoGalleryItem, PhotoGalleryItem.upload_key == Upload.key)
152 .where(PhotoGalleryItem.gallery_id == user.profile_gallery_id)
153 .order_by(PhotoGalleryItem.position)
154 .limit(1)
155 ).scalar_one_or_none()
158def has_avatar_photo_expression(user: type[User] | User) -> ColumnElement[bool]:
159 """
160 Returns an EXISTS expression that checks if a user has at least one photo in their profile gallery.
162 Can be used with a User instance or the User class (for SQL expressions).
164 Usage:
165 # In a query filter
166 statement.where(has_avatar_photo_expression(User))
168 # With a concrete value
169 session.execute(select(has_avatar_photo_expression(user))).scalar()
170 """
171 return exists(select(literal(1)).where(PhotoGalleryItem.gallery_id == user.profile_gallery_id))