Ejemplo n.º 1
0
    def list(self, request, *args, **kwargs):
        queryset_object = self.queryset.filter(user=request.user.pk)
        if not queryset_object:
            raise NotFoundException()
        page = self.paginate_queryset(self.queryset)
        if page is None:
            raise NotFoundException()

        serializer = self.serializer_class(page, many=True)
        return self.get_paginated_response(serializer.data)
Ejemplo n.º 2
0
    def list(self, request, *args, **kwargs):
        queryset_object = self.queryset.filter(
            **{f"{self.from_field}__user": request.user},
            **{f"{self.to_field}__user": request.user},
        )
        if not queryset_object:
            raise NotFoundException()

        page = self.paginate_queryset(queryset_object)
        if page is None:
            raise NotFoundException()

        serializer = self.serializer_class(page, many=True)
        return self.get_paginated_response(serializer.data)
Ejemplo n.º 3
0
def create_player(
        token: UUID4,
        data: schema.UserRegistrationIn,
        session: db.Session = Depends(get_session),
        _=Depends(anonymous_required),
):
    """Create a new player using the token obtained by requesting an invite.

    Will fail if requested by an authenticated user.
    """
    invite = session.query(Invite).filter(Invite.uuid == token).first()
    if invite is None:
        raise NotFoundException(
            detail="Token not found. Please request a new invite.")
    user = create_user(
        session,
        invite.email,
        data.password,
        username=data.username,
        description=data.description,
        newsletter_opt_in=data.newsletter_opt_in,
    )
    session.delete(invite)
    session.commit()
    access_token = access_token_for_user(user)
    return {"access_token": access_token, "token_type": "bearer", "user": user}
Ejemplo n.º 4
0
 def outgoing(self, request, pk):
     expenses = ExpenseTransaction.objects.select_related().filter(asset=pk)
     page = self.paginate_queryset(expenses)
     if page is None:
         raise NotFoundException()
     serializer = ExpenseTransactionSerializer(page, many=True)
     return self.get_paginated_response(serializer.data)
Ejemplo n.º 5
0
def get_user_data(badge: str, session: db.Session = Depends(get_session)):
    """Return public user information for any user."""
    user = (session.query(User).filter(User.badge == badge,
                                       User.is_banned.is_(False)).first())
    if not user:
        raise NotFoundException(detail="User not found.")
    return user
Ejemplo n.º 6
0
def request_password_reset(
        data: UserEmailIn,
        session: db.Session = Depends(get_session),
        _=Depends(anonymous_required),
):
    """Request a reset password link for the given email."""
    email = data.email.lower()
    user: User = session.query(User).filter(User.email == email).first()
    if not user:
        raise NotFoundException(detail="No account found for email.")
    if user.is_banned:
        raise BannedUserException()
    user.reset_uuid = uuid.uuid4()
    session.commit()
    if not send_message(
            recipient=user.email,
            template_id=settings.sendgrid_reset_template,
            data={
                "reset_token": str(user.reset_uuid),
                "email": user.email
            },
    ):
        if settings.debug:
            logger.debug(f"RESET TOKEN FOR {email}: {user.reset_uuid}")
        raise APIException(
            detail=
            "Unable to send password reset email; please contact Skaak#0007 on Discord."
        )
    return {
        "detail": "A link to reset your password has been sent to your email!"
    }
Ejemplo n.º 7
0
 def retrieve(self, request, pk):
     try:
         obj = self.queryset.get(
             pk=pk,
             **{f"{self.from_field}__user": request.user},
             **{f"{self.to_field}__user": request.user},
         )
     except ObjectDoesNotExist:
         raise NotFoundException()
     return Response(self.serializer_class(obj).data)
Ejemplo n.º 8
0
 def destroy(self, request, pk):
     try:
         obj = self.queryset.get(
             pk=pk,
             **{f"{self.from_field}__user": request.user},
             **{f"{self.to_field}__user": request.user},
         )
     except ObjectDoesNotExist:
         raise NotFoundException()
     return super().destroy(request, pk)
Ejemplo n.º 9
0
    def has_permission(self, request, view):
        try:
            obj = self.model.objects.select_related().get(
                pk=view.kwargs.get("pk"))
        except ObjectDoesNotExist:
            raise NotFoundException()

        if obj.user == request.user:
            return True

        raise ForbiddenException()
Ejemplo n.º 10
0
def get_card(stub: str,
             show_legacy: bool = False,
             session: db.Session = Depends(get_session)):
    """Returns the most basic information about this card."""
    query = session.query(Card.json).filter(Card.stub == stub)
    if show_legacy:
        query = query.filter(Card.is_legacy.is_(True))
    else:
        query = query.filter(Card.is_legacy.is_(False))
    card_json = query.join(
        Card.release).filter(Release.is_public == True).scalar()
    if not card_json:
        raise NotFoundException(detail="Card not found.")
    return card_json
Ejemplo n.º 11
0
def reset_password(
        token: UUID4,
        data: UserSetPasswordIn,
        session: db.Session = Depends(get_session),
        _=Depends(anonymous_required),
):
    """Reset the password for account associated with the given reset token."""
    user = session.query(User).filter(User.reset_uuid == token).first()
    if user is None:
        raise NotFoundException(
            detail="Token not found. Please request a new password reset.")
    user.password = generate_password_hash(data.password)
    user.reset_uuid = None
    session.commit()
    access_token = access_token_for_user(user)
    return {"access_token": access_token, "token_type": "bearer", "user": user}
Ejemplo n.º 12
0
def update_release(
        release_stub: str,
        data: ReleaseIn,
        session: db.Session = Depends(get_session),
        _: "User" = Depends(admin_required),
):
    """Update a release.

    **Admin only.**
    """
    release = (session.query(Release).filter(
        Release.stub == release_stub, Release.is_legacy.is_(False)).first())
    if not release:
        raise NotFoundException(detail="Release not found.")
    release.is_public = data.is_public
    session.commit()
    return release
Ejemplo n.º 13
0
def edit_snapshot(
    snapshot_id: int,
    data: SnapshotEditIn,
    session: db.Session = Depends(get_session),
    current_user: "******" = Depends(login_required),
):
    """Edit a snapshot's title or description

    Users can use this to update the descriptions of snapshots that have already been published. Admins can use this
    endpoint to moderate any snapshot that has been published on the site (as long as they include moderation notes).

    Title and description can be intentionally cleared by passing in an empty string for one or the other.
    """
    # First look up the snapshot
    deck: Deck = session.query(Deck).get(snapshot_id)
    # Run basic validation to make sure they have access to this snapshot (and it is indeed a snapshot)
    if not deck:
        raise NotFoundException(detail="No such snapshot found.")
    if not current_user.is_admin and deck.user_id != current_user.id:
        raise NoUserAccessException(detail="You cannot edit a snapshot you do not own.")
    if not deck.is_snapshot:
        raise APIException(detail="Not a valid snapshot.")
    # Ensure admins pass in moderation notes
    if current_user.is_admin:
        if deck.user_id != current_user.id:
            if not data.moderation_notes:
                raise APIException(detail="Moderation notes are required.")
            deck.moderation_notes = data.moderation_notes
            deck.is_moderated = True
            if data.description is not None and data.description != deck.description:
                deck.original_description = deck.description
    elif data.moderation_notes is not None:
        raise APIException(detail="You do not have permission to moderate snapshots.")
    # Now that we've verified everything is kosher, update our object
    for field in ("title", "description"):
        value = getattr(data, field)
        if value:
            setattr(deck, field, value)
        elif value is not None:
            # If the value is falsey (empty string) but not none, that means they intentionally want it cleared
            setattr(deck, field, None)
    session.commit()
    return deck_to_dict(session, deck=deck)
Ejemplo n.º 14
0
def get_private_deck(
    direct_share_uuid: UUID4,
    session: db.Session = Depends(get_session),
):
    """Fetch a single deck using its direct share UUID.

    This endpoint returns just a specific deck or snapshot based on its direct share UUID. This is
    primarily intended for loading a deck into an external application such as TableTop Simulator
    or Ashteki, but can also be used to privately share access to a deck with another user.
    """
    deck = (
        session.query(Deck)
        .filter(Deck.direct_share_uuid == direct_share_uuid, Deck.is_deleted.is_(False))
        .first()
    )
    if not deck:
        raise NotFoundException(
            detail="No such deck; it might have been deleted, or your share ID might be wrong."
        )
    deck_dict = deck_to_dict(session, deck=deck)
    return deck_dict
Ejemplo n.º 15
0
def moderate_user(
        badge: str,
        updates: schema.UserModerationIn,
        session: db.Session = Depends(get_session),
        current_user: "******" = Depends(admin_required),
):
    """**Admin only.** Ban a user; or moderate their username or description."""
    user: User = session.query(User).filter(User.badge == badge).first()
    if not user:
        raise NotFoundException(detail="User not found.")
    if user.id == current_user.id:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="You cannot moderate yourself.",
        )
    update_dict = updates.dict(exclude_unset=True)
    if "is_banned" in update_dict:
        user.is_banned = update_dict["is_banned"]
        user.moderation_notes = update_dict["moderation_notes"]
    else:
        for key, value in update_dict.items():
            setattr(user, key, value)
    session.commit()
    return user
Ejemplo n.º 16
0
    def create(self, request, *args, **kwargs):
        serializer = self.serializer_class(data=request.data)
        serializer.is_valid(raise_exception=True)

        try:
            from_id = request.data[self.from_field]["pk"]
            to_id = request.data[self.to_field]["pk"]
        except:
            raise BadRequestException()

        try:
            from_qs = self.from_model.objects.get(
                pk=from_id, user=request.user.pk
            )
            to_qs = self.to_model.objects.get(pk=to_id, user=request.user.pk)
        except ObjectDoesNotExist:
            raise NotFoundException()

        transaction = self.model_class(**serializer.data)
        setattr(transaction, self.from_field, from_qs)
        setattr(transaction, self.to_field, to_qs)
        transaction.save()

        return Response({"pk": transaction.pk}, status=status.HTTP_201_CREATED)
Ejemplo n.º 17
0
def list_snapshots(
    request: Request,
    deck_id: int,
    show_public_only: bool = Query(
        False,
        description="Only affects output if the current user is the owner of the deck (private snapshots will only be included for owners).",
    ),
    order: PaginationOrderOptions = PaginationOrderOptions.desc,
    paging: PaginationOptions = Depends(paging_options),
    session: db.Session = Depends(get_session),
    current_user: "******" = Depends(get_current_user),
):
    """List snapshots saved for a given deck.

    The `show_public_only` query parameter only does anything if the current user is the owner of the deck (users who
    do not own decks can only ever see public snapshots, so no private snapshots will be included even if they ask
    for them).
    """
    source_deck: Deck = session.query(Deck).get(deck_id)
    if not source_deck or source_deck.is_deleted or source_deck.is_snapshot:
        raise NotFoundException(detail="Deck not found.")
    query = session.query(Deck).filter(
        Deck.is_deleted.is_(False),
        Deck.is_snapshot.is_(True),
        Deck.source_id == source_deck.id,
    )
    if (
        current_user.is_anonymous()
        or current_user.id != source_deck.user_id
        or show_public_only is True
    ):
        query = query.filter(Deck.is_public.is_(True))
    query = query.options(db.joinedload(Deck.user)).order_by(
        getattr(Deck.created, order)()
    )
    return paginate_deck_listing(query, session, request, paging)
Ejemplo n.º 18
0
def clone_deck(
    deck_id: int,
    direct_share_uuid: UUID4 = Query(
        None,
        description="Optional direct share UUID, if cloning a privately shared deck.",
    ),
    session: db.Session = Depends(get_session),
    current_user: "******" = Depends(login_required),
):
    """Clone a snapshot, deck, or private share.

    Allows users to create a new deck that is an exact copy of:

    * one of their own decks
    * a public snapshot of someone else's deck
    * a privately shared deck or snapshot

    Returns a copy of the deck suitable for editing.

    Note that unlike the legacy site, cloning a deck in v2 will automatically create an initial snapshot of the source
    deck. This allows viewing the source deck even if it is deleted, overwritten, or otherwise inaccessible.
    """
    # Simple check if the target exists first (no need for joins)
    snapshot_or_deck_filters = [
        db.and_(
            Deck.is_public.is_(True),
            Deck.is_snapshot.is_(True),
        ),
        Deck.user_id == current_user.id,
    ]
    if direct_share_uuid:
        snapshot_or_deck_filters.append(
            Deck.direct_share_uuid == direct_share_uuid,
        )
    valid_deck_filters = (
        db.or_(*snapshot_or_deck_filters),
        Deck.id == deck_id,
        Deck.is_legacy.is_(False),
        Deck.is_deleted.is_(False),
    )
    deck = session.query(Deck.id).filter(*valid_deck_filters).first()
    if not deck:
        raise NotFoundException(detail="Invalid ID for cloning.")
    # Then we grab a new entity_id first because it causes a commit and kills the process otherwise
    entity_id = create_entity(session)
    # Then we can finally grab our full deck and copy it
    deck = (
        session.query(Deck)
        .options(
            db.joinedload("cards"),
            db.joinedload("dice"),
            db.joinedload("selected_cards"),
        )
        .filter(*valid_deck_filters)
        .first()
    )
    # Create a clone of our deck object (transient cloning was too error-prone, so we're doing everything by hand)
    cloned_deck = Deck(
        entity_id=entity_id,
        title=f"Copy of {deck.title}",
        description=deck.description,
        # Only track source IDs if we own the source or it's a public snapshot
        source_id=deck.id
        if current_user.id == deck.user_id or (deck.is_snapshot and deck.is_public)
        else None,
        user_id=current_user.id,
        phoenixborn_id=deck.phoenixborn_id,
    )
    session.add(cloned_deck)
    session.commit()
    for die in deck.dice:
        session.add(
            DeckDie(deck_id=cloned_deck.id, die_flag=die.die_flag, count=die.count)
        )
    for card in deck.cards:
        session.add(
            DeckCard(deck_id=cloned_deck.id, card_id=card.card_id, count=card.count)
        )
    for card in deck.selected_cards:
        session.add(
            DeckSelectedCard(
                deck_id=cloned_deck.id,
                card_id=card.card_id,
                tutor_card_id=card.tutor_card_id,
                is_first_five=card.is_first_five,
                is_paid_effect=card.is_paid_effect,
            )
        )
    session.commit()
    # Finally create an initial snapshot for the deck so we can see its original state even if the source changes
    create_snapshot_for_deck(
        session,
        current_user,
        cloned_deck,
        title=f"Source: {deck.title}",
        is_public=False,
        include_first_five=True,
    )
    return deck_to_dict(session, deck=cloned_deck)
Ejemplo n.º 19
0
def get_card_details(stub: str,
                     show_legacy: bool = False,
                     session: db.Session = Depends(get_session)):
    """Returns the full details about the card for use on the card details page"""
    card = (session.query(Card).join(Card.release).options(
        db.contains_eager(Card.release)).filter(
            Card.stub == stub,
            Card.is_legacy.is_(show_legacy),
            Release.is_public == True,
        ).scalar())
    if not card:
        raise NotFoundException(detail="Card not found.")

    # Gather up all related cards; there are two scenarios here: not a Phoenixborn card, in which
    #  case we just look up for the cards that can summon and down for the conjurations that can
    #  be summoned by one or more of those cards. Or we look up the Phoenixborn, unique, and all
    #  related conjurations
    related_cards = {}
    phoenixborn = None
    summons: Optional[list] = None
    if card.phoenixborn or card.card_type == "Phoenixborn":
        # Grab all cards related to this Phoenixborn
        if card.phoenixborn:
            phoenixborn = (session.query(Card).filter(
                Card.name == card.phoenixborn,
                Card.card_type == "Phoenixborn",
                Card.is_legacy.is_(show_legacy),
            ).first())
        else:
            phoenixborn = card
        phoenixborn_conjurations = gather_conjurations(phoenixborn)
        phoenixborn_unique = (session.query(Card).filter(
            Card.phoenixborn == phoenixborn.name,
            Card.card_type.notin_(
                ("Conjuration", "Conjured Alteration Spell")),
            Card.is_legacy.is_(show_legacy),
        ).first())
        phoenixborn_unique_conjurations = gather_conjurations(
            phoenixborn_unique)
        related_cards["phoenixborn"] = _card_to_minimal_card(phoenixborn)
        if phoenixborn_conjurations:
            related_cards["phoenixborn_conjurations"] = [
                _card_to_minimal_card(x) for x in phoenixborn_conjurations
            ]
            if card.id in [x.id for x in phoenixborn_conjurations]:
                summons = [phoenixborn]
        if phoenixborn_unique:
            related_cards["phoenixborn_unique"] = _card_to_minimal_card(
                phoenixborn_unique)
        if phoenixborn_unique_conjurations:
            related_cards["phoenixborn_unique_conjurations"] = [
                _card_to_minimal_card(x)
                for x in phoenixborn_unique_conjurations
            ]
            if card.id in [x.id for x in phoenixborn_unique_conjurations]:
                summons = [phoenixborn_unique]
    else:
        # Check to see if we have any conjurations that we need to map to this card
        # We want to look up things in a different order depending on whether we're looking at
        #  conjuration or not (because if we always start with root summons, we'll lose alternate
        #  summoning cards when looking at a root summon)
        if card.card_type.startswith("Conjur"):
            summons = gather_root_summons(card)
            all_conjurations = []
            for root_card in summons:
                all_conjurations = all_conjurations + gather_conjurations(
                    root_card)
            ids = set()
            conjurations = []
            for conjuration in all_conjurations:
                if conjuration.id not in ids:
                    ids.add(conjuration.id)
                    conjurations.append(conjuration)
        else:
            conjurations = gather_conjurations(card)
            all_summoning_cards = []
            for conjuration in conjurations:
                all_summoning_cards = all_summoning_cards + gather_root_summons(
                    conjuration)
            ids = set()
            summons = []
            for summon in all_summoning_cards:
                if summon.id not in ids:
                    ids.add(summon.id)
                    summons.append(summon)
        # Only return anything if this card has conjurations related to it
        if conjurations:
            related_cards["summoning_cards"] = [
                _card_to_minimal_card(x) for x in summons
            ]
            related_cards["conjurations"] = [
                _card_to_minimal_card(x) for x in conjurations
            ]

    # Gather stats
    # If we're looking at a conjuration, then make sure that we gather stats for everything that can
    #  summon that conjuration
    if card.card_type.startswith("Conjur"):
        root_card_ids = [x.id for x in summons] if summons else []
    else:
        root_card_ids = [card.id]
    # We only look up the Phoenixborn if it's in our root summons array (otherwise we might be
    #  looking at a Phoenixborn unique, and we'll get accurate counts for it in the next query)
    phoenixborn_counts = (session.query(
        db.func.count(Deck.id).label("decks"),
        db.func.count(db.func.distinct(Deck.user_id)).label("users"),
    ).filter(Deck.phoenixborn_id == phoenixborn.id,
             Deck.is_snapshot.is_(False)).first() if phoenixborn
                          and phoenixborn.id in root_card_ids else None)
    card_counts = (session.query(
        db.func.count(DeckCard.deck_id).label("decks"),
        db.func.count(db.func.distinct(Deck.user_id)).label("users"),
    ).join(Deck, Deck.id == DeckCard.deck_id).filter(
        DeckCard.card_id.in_(root_card_ids),
        Deck.is_snapshot.is_(False),
    ).first() if root_card_ids else None)
    counts = {"decks": 0, "users": 0}
    if phoenixborn_counts:
        counts["decks"] += phoenixborn_counts.decks
        counts["users"] += phoenixborn_counts.users
    if card_counts:
        counts["decks"] += card_counts.decks
        counts["users"] += card_counts.users

    # Grab preconstructed deck, if available
    preconstructed = (session.query(Deck.source_id, Deck.title).join(
        DeckCard, DeckCard.deck_id == Deck.id).filter(
            Deck.is_snapshot.is_(True),
            Deck.is_public.is_(True),
            Deck.is_preconstructed.is_(True),
            Deck.is_legacy.is_(show_legacy),
            DeckCard.card_id.in_(root_card_ids),
        ).first())

    return {
        "card": card.json,
        "usage": counts,
        "preconstructed_deck": {
            "id": preconstructed.source_id,
            "title": preconstructed.title,
        } if preconstructed else None,
        "related_cards": related_cards,
    }
Ejemplo n.º 20
0
def get_deck(
    deck_id: int,
    show_saved: bool = Query(
        False,
        description="When viewing a source deck ID, whether the actual latest save should be returned.",
    ),
    session: db.Session = Depends(get_session),
    current_user: "******" = Depends(get_current_user),
):
    """Read a single deck's details.

    This endpoint will return different information depending on the requesting user. For decks the
    user did not create (and for anonymous users):

    * passing a source deck's ID will always return the most recent published snapshot
    * passing a published snapshot's ID will return that snapshot
    * passing a private snapshot's ID (or a deck ID without any public snapshots) will throw an
      authentication error

    For authenticated users who own the deck:

    * passing a source deck's ID will return the most recent published snapshot
    * passing a source deck's ID with the query parameter `show_saved=true` will return the most
      recent saved copy of that deck (allows previewing how a deck will display prior to creating
      a public snapshot)
    * passing any snapshot's ID will return that snapshot
    """
    source_deck: Deck = session.query(Deck).get(deck_id)
    if not source_deck:
        raise NotFoundException(detail="Deck not found.")
    own_deck = (
        not current_user.is_anonymous() and source_deck.user_id == current_user.id
    )
    if source_deck.is_deleted:
        raise NotFoundException(detail="Deck not found.")
    if (source_deck.is_snapshot and not source_deck.is_public and not own_deck) or (
        not own_deck and show_saved
    ):
        raise NoUserAccessException(detail="You do not have access to this deck.")
    # Check for the instances where we just return the source deck (separated into discrete
    #  conditionals to make things more readable)
    # Public snapshots simply get returned
    if source_deck.is_snapshot and source_deck.is_public:
        deck = source_deck
    # Private snapshots get returned if the user owns the deck
    elif source_deck.is_snapshot and own_deck:
        deck = source_deck
    # The actual deck gets returned if we are showing the latest saved copy
    elif not source_deck.is_snapshot and own_deck and show_saved:
        deck = source_deck
    # By default, re-route to the latest public snapshot
    else:
        deck: Deck = (
            session.query(Deck)
            .filter(
                Deck.source_id == source_deck.id,
                Deck.is_snapshot.is_(True),
                Deck.is_public.is_(True),
                Deck.is_deleted.is_(False),
            )
            .order_by(Deck.created.desc())
            .first()
        )
        if not deck:
            raise NotFoundException(detail="Deck not found.")

    deck_dict = deck_to_dict(session, deck=deck, include_comment_entity_id=True)

    # Add our `is_saved` flag, if we're viewing a saved deck
    if not source_deck.is_snapshot and show_saved:
        deck_dict["is_saved"] = True

    # Check to see if we can include the direct_share_uuid
    if deck.is_public or own_deck:
        deck_dict["direct_share_uuid"] = deck.direct_share_uuid

    # And finally look up the releases that are required by this deck
    card_stubs = set(x["stub"] for x in deck_dict["cards"])
    card_stubs.add(deck_dict["phoenixborn"]["stub"])
    release_stubs = set()
    # Even if cards don't require a particular set, check for dice
    dice_to_release = {
        "ceremonial": "master-set",
        "charm": "master-set",
        "illusion": "master-set",
        "natural": "master-set",
        "divine": "the-law-of-lions",
        "sympathy": "the-song-of-soaksend",
        "time": "the-breaker-of-fate",
    }
    for die in deck_dict["dice"]:
        release_stubs.add(dice_to_release[die["name"]])
    release_results = (
        session.query(Release, Deck)
        .outerjoin(Card, Card.release_id == Release.id)
        .outerjoin(Deck, Deck.preconstructed_release == Release.id)
        .filter(
            db.or_(
                Release.stub.in_(release_stubs),
                Card.stub.in_(card_stubs),
            ),
            Release.is_legacy.is_(bool(deck_dict.get("is_legacy"))),
        )
        .order_by(Release.id.asc())
        .distinct(Release.id)
        .all()
    )
    release_data = []
    for result in release_results:
        release_data.append(
            {
                "name": result.Release.name,
                "stub": result.Release.stub,
                "is_legacy": result.Release.is_legacy,
                "preconstructed_deck_id": (
                    result.Deck.source_id
                    # Don't bother including the preconstructed ID if the viewed deck is the precon
                    if result.Deck and result.Deck.source_id != deck_dict["source_id"]
                    else None
                ),
            }
        )

    deck_details = {
        "releases": release_data,
        "deck": deck_dict,
    }
    if show_saved:
        source_id = (
            source_deck.id if not source_deck.is_snapshot else source_deck.source_id
        )
        deck_details["has_published_snapshot"] = bool(
            session.query(Deck.id)
            .filter(
                Deck.source_id == source_id,
                Deck.is_snapshot.is_(True),
                Deck.is_public.is_(True),
                Deck.is_deleted.is_(False),
            )
            .count()
        )

    return deck_details