def save_collection( collection: List[str], session: db.Session = Depends(get_session), current_user: "******" = Depends(login_required), ): """Update the user's collection in place. PUT a list of release slugs to set them as the user's collection (e.g. `['master-set', 'the-frostdale-giants']`. **This is not a patch!** You must pass the entire list of the user's collections every time. """ # Clear out our existing releases session.query(UserRelease).filter( UserRelease.user_id == current_user.id).delete() session.commit() release_ids = ((session.query(Release.id).filter( Release.is_legacy.is_(False), Release.is_public.is_(True), Release.stub.in_(collection), ).all()) if collection else None) if release_ids: for row in release_ids: session.add(UserRelease(user_id=current_user.id, release_id=row.id)) session.commit() query = get_releases_query(session=session, current_user=current_user) return query.all()
def log_out( session: db.Session = Depends(get_session), jwt_payload: dict = Depends(get_auth_token), current_user: "******" = Depends(login_required), ): """Log a user out and revoke their JWT token's access rights. It's a good idea to invoke this whenever an authenticated user logs out, because tokens can otherwise be quite long-lived. """ # Do some quick clean-up to keep our table lean and mean; deletes any tokens that expired more than 24 hours ago session.query(UserRevokedToken).filter( UserRevokedToken.expires < dt.datetime.utcnow() - dt.timedelta(days=1)).delete(synchronize_session=False) session.commit() # Then add our newly revoked token expires_at = dt.datetime.fromtimestamp(jwt_payload["exp"], tz=dt.timezone.utc) # No need to do `.get("jti")` here because a missing JTI would result in a credentials error in the dependencies revoked_hex = jwt_payload["jti"] revoked_uuid = uuid.UUID(hex=revoked_hex) revoked_token = UserRevokedToken(revoked_uuid=revoked_uuid, user_id=current_user.id, expires=expires_at) session.add(revoked_token) session.commit() return {"detail": "Token successfully revoked."}
def refresh_stream_for_entity(session: db.Session, entity_id: int, entity_type: str, source_entity_id: int): """Creates or updates the Stream entry for the given entity **Please note:** this method does not commit the changes! You must flush the session in the invoking method. """ if entity_type == "deck": entity = (session.query(Stream).filter( Stream.source_entity_id == source_entity_id, Stream.entity_type == "deck", ).first()) else: entity = session.query(Stream).filter( Stream.entity_id == entity_id).first() if not entity: entity = Stream( entity_id=entity_id, entity_type=entity_type, source_entity_id=source_entity_id, ) elif entity_type == "deck": # Decks are a special case; we update the Stream entity because the snapshots effectively # replace one another as far as most users are concerned entity.posted = datetime.utcnow() entity.entity_id = entity_id else: # Ignore comment edits return session.add(entity)
def test_get_releases(client: TestClient, session: db.Session): """Releases endpoint must return a list of all releases""" master_set = Release(name="Master Set") master_set.is_public = True session.add(master_set) session.commit() response = client.get("/v2/releases") assert response.status_code == status.HTTP_200_OK assert len(response.json()) == 1
def test_get_releases_public_only(client: TestClient, session: db.Session): """Releases list must only include public releases""" master_set = Release(name="Master Set") master_set.is_public = True session.add(master_set) session.add(Release(name="Unreleased")) session.commit() response = client.get("/v2/releases") assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data) == 1 assert data[0]["stub"] == master_set.stub
def test_patch_release_non_admin(client: TestClient, session: db.Session): """Patching a release must require admin access""" master_set = Release(name="Master Set") session.add(master_set) session.commit() user, token = create_user_token(session) response = client.patch( f"/v2/releases/{master_set.stub}", json={"is_public": True}, headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == status.HTTP_403_FORBIDDEN
def test_patch_release(client: TestClient, session: db.Session): """Patching a release to set it public must work""" master_set = Release(name="Master Set") session.add(master_set) session.commit() assert master_set.is_public == False admin, token = create_admin_token(session) response = client.patch( f"/v2/releases/{master_set.stub}", json={"is_public": True}, headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == status.HTTP_200_OK session.refresh(master_set) assert master_set.is_public == True
def test_put_releases_bad_release(client: TestClient, session: db.Session): """Putting a nonsense stub must work""" master_set = Release(name="Master Set") master_set.is_public = True session.add(master_set) session.commit() user, token = create_user_token(session) response = client.put( "/v2/releases/mine", json=["fake-set"], headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == status.HTTP_200_OK data = response.json() assert data[0]["stub"] == master_set.stub assert data[0]["is_mine"] == False
def test_release_filtration(client: TestClient, session: db.Session): """Filtering cards by owned releases works properly.""" # Create our user, and setup their collection user, token = create_user_token(session) master_set = session.query(Release).filter( Release.stub == "master-set").first() user_release = UserRelease(user_id=user.id, release_id=master_set.id) session.add(user_release) session.commit() # Verify that the filter works response = client.get( "/v2/cards", params={"releases": "mine"}, headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200, response.json() assert len(response.json()["results"]) == 7, response.json()
def create_card(data: CardIn, session: db.Session = Depends(get_session), _=Depends(admin_required)): """Admin-only. Adds a new card to the database. Releases will be implicitly created (and marked as private), if they do not already exist. There is no need to create the release ahead, as a result, which means you can populate a release entirely with this endpoint, and then just publish it when ready. Note that you must create conjurations *before* the cards that reference them. """ # Implicitly create the release, if necessary release_stub = stubify(data.release) if not (release := (session.query(Release).filter( Release.stub == release_stub, Release.is_legacy.is_(False)).one_or_none())): release = Release(name=data.release, stub=release_stub) session.add(release) session.commit()
def test_get_releases_legacy(client: TestClient, session: db.Session): """Releases list must only show legacy releases when they are requested""" master_set = Release(name="Master Set") master_set.is_public = True session.add(master_set) core_set = Release(name="Core Set") core_set.is_legacy = True core_set.is_public = True session.add(core_set) session.commit() response = client.get("/v2/releases") assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data) == 1 assert data[0]["stub"] == master_set.stub response = client.get("/v2/releases", params={"show_legacy": True}) assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data) == 1 assert data[0]["stub"] == core_set.stub
def test_phg_release_filtration(client: TestClient, session: db.Session): """Must be able to filter for only PHG release when looking at legacy cards.""" release = Release(name="PHG") release.is_public = True release.is_legacy = True release.is_phg = True session.add(release) fan_release = Release(name="Fan") fan_release.is_public = True fan_release.is_legacy = True session.add(fan_release) session.commit() phg_card = create_card( session, name="A", card_type="Action Spell", placement="Discard", release=release, text="Text.", ) fan_card = create_card( session, name="B", card_type="Action Spell", placement="Discard", release=fan_release, text="Text.", ) phg_card.is_legacy = True fan_card.is_legacy = True session.commit() response = client.get("/v2/cards", params={ "show_legacy": True, "releases": "phg" }) assert response.status_code == 200 assert len(response.json()["results"]) == 1, response.json()
def test_put_releases(client: TestClient, session: db.Session): """Putting my releases must work""" master_set = Release(name="Master Set") master_set.is_public = True session.add(master_set) first_expansion = Release(name="First Expansion") first_expansion.is_public = True session.add(first_expansion) session.commit() user, token = create_user_token(session) assert (session.query(UserRelease).filter( UserRelease.user_id == user.id).count() == 0) response = client.put( "/v2/releases/mine", json=[master_set.stub], headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == status.HTTP_200_OK data = response.json() assert data[0]["stub"] == master_set.stub assert data[0]["is_mine"] == True assert data[1]["is_mine"] == False
def update_subscription_for_user( session: db.Session, user: "******", source_entity_id: int, last_seen_entity_id: int = None, ): """Create or update the user's subscription to the given entity ID. **Please note:** this method does not commit the changes! You must flush the session in the invoking method. """ subscription = (session.query(Subscription).filter( Subscription.user_id == user.id, Subscription.source_entity_id == source_entity_id, ).first()) if not subscription: subscription = Subscription( user_id=user.id, source_entity_id=source_entity_id, last_seen_entity_id=last_seen_entity_id, ) else: subscription.last_seen_entity_id = last_seen_entity_id session.add(subscription)
def test_get_releases_mine(client: TestClient, session: db.Session): """Releases list must mark which releases are in the user's collection""" master_set = Release(name="Master Set") master_set.is_public = True session.add(master_set) first_expansion = Release(name="First Expansion") first_expansion.is_public = True session.add(first_expansion) session.commit() user, token = create_user_token(session) session.add(UserRelease(release_id=master_set.id, user_id=user.id)) session.commit() response = client.get( "/v2/releases", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == status.HTTP_200_OK data = response.json() assert data[0]["stub"] == master_set.stub assert data[0]["is_mine"] == True assert data[1]["is_mine"] == False
def create_or_update_deck( session: db.Session, current_user: "******", phoenixborn: Card, deck_id: int = None, title: str = None, description: str = None, dice: List[Dict[str, Union[str, int]]] = None, cards: List[Dict[str, Union[str, int]]] = None, first_five: List[str] = None, effect_costs: List[str] = None, tutor_map: Dict[str, str] = None, ) -> "Deck": """Creates or updates a deck in place.""" now = datetime.utcnow() if deck_id: deck = (session.query(Deck).options( db.joinedload("cards"), db.joinedload("dice"), db.joinedload("selected_cards"), ).get(deck_id)) deck.title = title deck.description = description deck.phoenixborn_id = phoenixborn.id deck.modified = now else: deck = Deck( entity_id=create_entity(session), title=title, description=description, user_id=current_user.id, phoenixborn_id=phoenixborn.id, is_snapshot=False, is_public=False, ) # Update the dice listing deck_dice: List[DeckDie] = [] total_dice = 0 if dice: for die_dict in dice: die = die_dict.get("name") count = die_dict.get("count") if count: if total_dice + count > 10: count = 10 - total_dice if count == 0: break total_dice = total_dice + count deck_dice.append( DeckDie(die_flag=DiceFlags[die].value, count=count)) deck.dice = deck_dice # And then the card listing deck_cards: List[DeckCard] = [] card_stub_counts = {x["stub"]: x["count"] for x in (cards or [])} card_stubs = set(card_stub_counts.keys()) if first_five: card_stubs.update(first_five) if effect_costs: card_stubs.update(effect_costs) if tutor_map: card_stubs.update(tutor_map.keys()) card_stubs.update(tutor_map.values()) minimal_cards = (session.query(Card.id, Card.stub, Card.name, Card.card_type, Card.phoenixborn).join(Card.release).filter( Card.stub.in_(card_stubs), Card.is_legacy.is_(False), Release.is_public == True, ).all()) for card in minimal_cards: # Minimal cards could include bogus cards thanks to first_five list and similar, so fall # back to zero to ensure this is something with a count count = card_stub_counts.get(card.stub, 0) # Make sure our count can't exceed 3 count = count if count <= 3 else 3 # Skip it if it's not part of the deck if count <= 0: continue if card.card_type == CardType.phoenixborn.value: raise PhoenixbornInDeck() if card.phoenixborn and card.phoenixborn != phoenixborn.name: raise BadPhoenixbornUnique(card) if card.card_type in ( CardType.conjuration.value, CardType.conjured_alteration_spell.value, ): raise ConjurationInDeck(card) deck_cards.append(DeckCard(card_id=card.id, count=count)) deck.cards = deck_cards # Save everything up! deck.selected_cards = [] session.add(deck) session.commit() # And finally set selected cards (first five, paid effects, and tutored cards; used for stats) # This happens after clearing them out above because SQLAlchemy cannot handle the three way # composite index (tries to insert duplicates instead of updating intelligently based on # tutor_card_id) stub_to_card = {x.stub: x for x in minimal_cards} selected_cards: List[DeckSelectedCard] = [] if effect_costs: for card_stub in effect_costs: card = stub_to_card.get(card_stub) # TODO: remove pragmas once I've updated to Python 3.10 and this coverage bug is fixed if not card: # pragma: no cover continue if first_five and card_stub not in first_five: selected_cards.append( DeckSelectedCard(card_id=card.id, is_paid_effect=True)) if first_five: for card_stub in first_five: card = stub_to_card.get(card_stub) if not card: # pragma: no cover continue selected_cards.append( DeckSelectedCard( card_id=card.id, is_first_five=True, is_paid_effect=card.stub in effect_costs if effect_costs else False, )) if tutor_map: for tutor_stub, card_stub in tutor_map.items(): tutor_card = stub_to_card.get(tutor_stub) card = stub_to_card.get(card_stub) if not tutor_card or not card: # pragma: no cover continue selected_cards.append( DeckSelectedCard(card_id=card.id, tutor_card_id=tutor_card.id)) deck.selected_cards = selected_cards session.commit() return deck
def create_snapshot_for_deck( session: db.Session, user: "******", deck: "Deck", title: str = None, description: str = None, is_public=False, preconstructed_release_id: int = None, include_first_five=False, ) -> "Deck": """Creates a snapshot for the given deck""" entity_id = create_entity(session) snapshot = Deck( entity_id=entity_id, title=title, description=description, # In the interim while we are saving the cards and dice and so forth, we mark this as private so that it doesn't # show up in any listings and break stuff in weird ways is_public=False, is_snapshot=True, is_preconstructed=bool(preconstructed_release_id), preconstructed_release=preconstructed_release_id, source_id=deck.id, user_id=user.id, phoenixborn_id=deck.phoenixborn_id, ) # Save our snapshot so that we have an ID session.add(snapshot) session.commit() # Now duplicate the cards, dice, and selected cards for the given deck if deck.cards: for deck_card in deck.cards: session.add( DeckCard( deck_id=snapshot.id, card_id=deck_card.card_id, count=deck_card.count, )) if deck.dice: for deck_die in deck.dice: session.add( DeckDie( deck_id=snapshot.id, die_flag=deck_die.die_flag, count=deck_die.count, )) if deck.selected_cards and (include_first_five or not is_public): for deck_selected_card in deck.selected_cards: session.add( DeckSelectedCard( deck_id=snapshot.id, card_id=deck_selected_card.card_id, tutor_card_id=deck_selected_card.tutor_card_id, is_first_five=deck_selected_card.is_first_five, is_paid_effect=deck_selected_card.is_paid_effect, )) # Flip our public flag now that we've populated the deck details, if necessary if is_public: snapshot.is_public = True # We also need to publish to the Stream for public snapshots refresh_stream_for_entity( session, entity_id=snapshot.entity_id, entity_type="deck", source_entity_id=deck.entity_id, ) # And finally we need to update the user's subscription to mark the last_seen_entity_id update_subscription_for_user( session, user=user, source_entity_id=deck.entity_id, last_seen_entity_id=snapshot.entity_id, ) session.commit() return snapshot
def create_cards_for_decks(session: db.Session): """This utility function populates a set of cards appropriate for testing deck building""" # First create our two releases master_set = Release("Master Set") master_set.is_public = True expansion = Release("Expansion") expansion.is_public = True session.add(master_set) session.add(expansion) session.commit() # Then create two Phoenixborns (with uniques), and 18 other cards spanning all card types # (to ensure that we can filter by release, preconstructed decks, etc.) card_dicts = [ { "name": "One Phoenixborn", "card_type": "Phoenixborn", "release": master_set, "text": "Command Strike: [[side]] - 2 [[basic]]: Do stuff.", "effect_magic_cost": "2 [[basic]]", "battlefield": 4, "life": 20, "spellboard": 5, }, { "name": "One Conjuration A", "card_type": "Conjuration", "release": master_set, "placement": "Battlefield", "text": "* Consume: Do stuff.", "life": 4, "attack": "X", "copies": 1, "recover": 3, "phoenixborn": "One Phoenixborn", }, { "name": "Summon One Conjuration A", "card_type": "Ready Spell", "release": master_set, "placement": "Spellboard", "cost": ["[[main]]"], "text": "[[main]] - [[exhaust]] - 1 [[charm:power]] - 1 [[natural:power]]: Place a [[One Conjuration A]] conjuration onto your battlefield.", "phoenixborn": "One Phoenixborn", }, { "name": "One Conjuration B", "card_type": "Conjuration", "release": master_set, "placement": "Battlefield", "text": "Unit Guard: Do stuff.", "attack": 0, "life": 2, "recover": 0, "copies": 2, }, { "name": "Summon One Conjuration B", "card_type": "Ready Spell", "release": master_set, "placement": "Spellboard", "cost": ["[[main]]", "1 [[charm:class]]"], "text": "[[main]] - [[exhaust]] - 1 [[natural:class]]: Place a [[One Conjuration B]] conjuration onto your battlefield.", }, { "name": "One Ready Spell A", "card_type": "Ready Spell", "release": master_set, "placement": "Spellboard", "cost": ["[[main]]"], "text": "[[side]] - [[exhaust]] - 2 [[charm:class]]: Do stuff.", }, { "name": "One Ready Spell B", "card_type": "Ready Spell", "release": master_set, "placement": "Spellboard", "cost": ["[[side]]", "1 [[basic]]"], "text": "[[main]] - [[exhaust]] - 1 [[natural:class]] or 1 [[sympathy:class]]: Do stuff.", }, { "name": "One Action Spell A", "card_type": "Action Spell", "release": master_set, "placement": "Discard", "cost": ["[[main]]", "2 [[natural:power]]"], "text": "Do stuff.", }, { "name": "One Action Spell B", "card_type": "Action Spell", "release": master_set, "placement": "Discard", "cost": ["[[main]]", "1 [[charm:class]]", "1 [[basic]]"], "text": "Do stuff.", }, { "name": "One Action Spell C", "card_type": "Action Spell", "release": master_set, "placement": "Discard", "cost": ["[[main]]", "1 [[charm:class]]", "1 [[basic]]"], "text": "Do stuff.", }, { "name": "One Reaction Spell", "card_type": "Reaction Spell", "release": master_set, "placement": "Discard", "cost": ["1 [[charm:power]]"], "text": "You may play this spell after an opponent targets a unit you control with a spell, ability, or dice power. Do stuff.", }, { "name": "One Alteration Spell", "card_type": "Alteration Spell", "release": master_set, "placement": "Unit", "cost": ["[[side]]", "1 [[natural:class]]"], "text": "This unit now has the following ability:\n\n* Armored 1: Do stuff.", "life": "+1", }, { "name": "One Ally", "card_type": "Ally", "release": master_set, "placement": "Battlefield", "cost": ["[[main]]", "1 [[charm:class]]"], "text": "Song of Sorrow: [[side]] - [[exhaust]]: Do stuff.", "attack": 1, "life": 1, "recover": 1, }, # And define cards for a single expansion { "name": "Two Conjured Alteration Spell", "card_type": "Conjured Alteration Spell", "release": expansion, "placement": "Unit", "life": "+1", "copies": 5, "phoenixborn": "Two Phoenixborn", }, { "name": "Two Phoenixborn", "text": "Ice Buff: [[side]] - [[exhaust]]: Attach an [[Two Conjured Alteration Spell]] conjured alteration spell to a target unit you control.", "card_type": "Phoenixborn", "release": expansion, "battlefield": 6, "life": 17, "spellboard": 4, }, { "name": "Two's Reaction Spell", "card_type": "Reaction Spell", "release": expansion, "placement": "Discard", "cost": ["2 [[basic]]"], "text": "You may play this spell after a unit you control is dealt damage by a unit's attack. Prevent that damage from being received. Destroy that target attacking unit.", "phoenixborn": "Two Phoenixborn", }, { "name": "Two Conjuration A", "card_type": "Conjuration", "release": expansion, "placement": "Battlefield", "text": "* Skin Morph 2: Do stuff.", "attack": 3, "life": 2, "recover": 0, "copies": 3, }, { "name": "Summon Two Conjuration A", "card_type": "Ready Spell", "release": expansion, "placement": "Spellboard", "cost": ["[[main]]"], "text": "[[main]] - [[exhaust]] - 2 [[natural:class]] - 1 [[basic]]: Place an [[Two Conjuration A]] conjuration onto your battlefield.\n\nFocus 2: Do stuff.", }, { "name": "Two Conjuration D", "card_type": "Conjuration", "release": expansion, "placement": "Battlefield", "text": "* Inheritance 1: Do stuff.", "attack": 3, "life": 2, "recover": 0, "copies": 6, }, { "name": "Two Conjuration C", "card_type": "Conjuration", "release": expansion, "placement": "Battlefield", "text": "Blossom: [[main]]: Place up to 2 [[Two Conjuration D]] conjurations onto your battlefield.", "attack": 0, "life": 2, "recover": 0, "copies": 3, }, { "name": "Two Conjuration B", "card_type": "Conjuration", "release": expansion, "placement": "Battlefield", "text": "* Germinate: When this unit is destroyed, place a [[Two Conjuration C]] conjuration onto your battlefield.", "attack": 2, "life": 1, "recover": 0, "copies": 3, }, { "name": "Summon Two Conjuration B", "card_type": "Ready Spell", "release": expansion, "placement": "Spellboard", "cost": ["[[main]]"], "text": "[[main]] - [[exhaust]] - 1 [[natural:class]] - 1 [[sympathy:class]]: Place an [[Two Conjuration B]] conjuration onto your battlefield.\n\nFocus 1: Do stuff.", }, { "name": "Two Ready Spell", "card_type": "Ready Spell", "release": expansion, "placement": "Spellboard", "cost": ["[[main]]"], "text": "[[main]] - [[exhaust]] - 1 [[natural:class]]: Do stuff.", }, { "name": "Two Reaction Spell", "card_type": "Reaction Spell", "release": expansion, "placement": "Discard", "cost": ["1 [[natural:class]]"], "text": "You may play this spell after a unit with a life value of 2 or less comes into play. Destroy that target unit.", }, { "name": "Two Alteration Spell", "card_type": "Alteration Spell", "release": expansion, "placement": "Unit", "cost": ["[[main]]", "1 [[natural:class]]"], "text": "* When attaching this spell, place 3 status tokens on this spell. Discard this spell when it no longer has any status tokens on it. As long as this spell is attached to this unit, this unit is considered to be exhausted. This unit now has the following ability:\n\n* Thaw: [[side]]: Remove 1 status token from a Deep Freeze alteration spell attached to this unit.", }, { "name": "Two Action Spell", "card_type": "Action Spell", "release": expansion, "placement": "Discard", "cost": ["[[main]]", "2 [[natural:class]]"], "text": "Deal 2 damage to a target unit. Remove 2 status tokens from that unit.", }, { "name": "Two Ally A", "card_type": "Ally", "release": expansion, "placement": "Battlefield", "cost": ["[[main]]", "2 [[natural:class]]"], "text": "* Armored 1: After this unit is dealt damage, prevent 1 damage from being received.", "attack": 3, "life": 1, "recover": 1, }, { "name": "Two Ally B", "card_type": "Ally", "release": expansion, "placement": "Battlefield", "cost": ["[[main]]", "1 [[sympathy:class]]", "1 [[basic]]"], "text": "* Last Orders 1: When this unit is destroyed, you may spend 1 [[basic]] to do stuff.\n\n* Inheritance 1: Do stuff.", "attack": 2, "life": 2, "recover": 1, }, { "name": "Two Ally C", "card_type": "Ally", "release": expansion, "placement": "Battlefield", "cost": ["[[main]]", "2 [[natural:class]]"], "text": "Slumbering 1: Do stuff.", "attack": 4, "life": 4, "recover": 2, }, ] # Create our cards for card_dict in card_dicts: create_card(session, **card_dict)
def create_entity(session: db.Session) -> int: """Creates and returns a new entity ID""" entity = Streamable() session.add(entity) session.commit() return entity.entity_id
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)
def _create_cards_for_filtration(session: db.Session, is_legacy=False): """Populates database with a minimum viable list of one of each card type""" # First create our two releases master_set = models.Release("Master Set") master_set.is_legacy = is_legacy master_set.is_public = True expansion = models.Release("First Expansion") expansion.is_legacy = is_legacy expansion.is_public = True session.add(master_set) session.add(expansion) session.commit() # Then create one of every type of card, with a mixture of all the different things that can be # included to ensure that the automatic dice sorting and so forth works properly card_dicts = [ { "name": "Example Conjuration", "card_type": "Conjuration", "placement": "Battlefield", "release": master_set, "attack": 0, "life": 2, "recover": 0, "copies": 3, }, { "name": "Example Conjured Alteration", "card_type": "Conjured Alteration Spell", "placement": "Unit", "phoenixborn": "Example Phoenixborn", "release": master_set, "text": "Whoops: 1 [[basic]] - 1 [[discard]]: Discard this spell.", "effect_magic_cost": "1 [[basic]]", "attack": "-2", "copies": 2, }, { "name": "Example Phoenixborn", "card_type": "Phoenixborn", "release": master_set, "text": "Mess With Them: [[main]] - 1 [[illusion:class]]: Place a [[Example Conjured Alteration]] conjured alteration spell on opponent's unit.", "effect_magic_cost": "1 [[illusion:class]]", "battlefield": 5, "life": 16, "spellboard": 4, "can_effect_repeat": True, }, { "name": "Summon Example Conjuration", "card_type": "Ready Spell", "placement": "Spellboard", "release": master_set, "cost": "[[main]] - 1 [[basic]] - [[side]] / 1 [[discard]]", "text": "1 [[charm:class]]: Place a [[Example Conjuration]] conjuration on your battlefield.", "effect_magic_cost": ["1 [[charm:class]]"], }, { "name": "Example Ally Conjuration", "card_type": "Conjuration", "placement": "Battlefield", "phoenixborn": "Example Phoenixborn", "release": master_set, "attack": 2, "life": 1, "recover": 0, "copies": 2, }, { "name": "Example Ally", "card_type": "Ally", "placement": "Battlefield", "phoenixborn": "Example Phoenixborn", "release": master_set, "cost": ["[[main]]", ["1 [[natural:power", "1 [[illusion:power]]"]], "text": "Stuffiness: [[main]] - [[exhaust]] - 1 [[natural:class]] / 1 [[illusion:class]]: Place a [[Example Ally Conjuration]] conjuration on your battlefield.", "effect_magic_cost": "1 [[natural:class]] / 1 [[illusion:class]]", "attack": 2, "life": 1, "recover": 1, }, { "name": "Example Alteration", "card_type": "Alteration Spell", "placement": "Unit", "release": master_set, "cost": "[[side]] - 1 [[basic]] - 1 [[discard]]", "attack": "+2", "recover": "-1", }, { "name": "Example Ready Spell", "card_type": "Ready Spell", "placement": "Spellboard", "release": expansion, "cost": "[[side]]", "text": "[[main]] - [[exhaust]]: Do more things.", }, { "name": "Example Action", "card_type": "Action Spell", "placement": "Discard", "release": expansion, "cost": "[[main]] - 1 [[time:power]] - 1 [[basic]]", "text": "If you spent a [[sympathy:power]] to pay for this card, do more stuff.", "alt_dice": ["sympathy"], }, { "name": "Example Reaction", "card_type": "Reaction Spell", "placement": "Discard", "release": expansion, "cost": "1 [[divine:class]] / 1 [[ceremonial:class]]", "text": "Do a happy dance.", }, ] cards = [] # Create our cards for card_dict in card_dicts: cards.append(create_card(session, **card_dict)) if is_legacy: for card in cards: card.is_legacy = True session.commit()
def create_card( session: db.Session, name: str, card_type: str, release: "Release", placement: str = None, text: str = None, cost: Union[List[str], str, None] = None, effect_magic_cost: Union[List[str], str, None] = None, can_effect_repeat: bool = False, dice: List[str] = None, alt_dice: List[str] = None, phoenixborn: str = None, attack: str = None, battlefield: str = None, life: str = None, recover: str = None, spellboard: str = None, copies: int = None, ) -> "Card": """Creates a card, generating the necessary JSON and cost info""" card = Card() card.name = name card.stub = stubify(name) card.phoenixborn = phoenixborn card.card_type = card_type card.placement = placement card.release_id = release.id card.search_text = f"{card.name}\n" if card.phoenixborn: card.search_text += f"{card.phoenixborn}\n" card.is_summon_spell = name.startswith("Summon ") existing_conjurations = None if text: # Remove apostrophes and formatting characters from search text to ensure words are treated as lexemes card.search_text += re.sub( r"\n+", " ", text.replace("[[", "").replace("]]", "").replace("'", "")) # Check for conjurations before we do any more work conjuration_stubs = set() for match in re.finditer( r"\[\[([A-Z][A-Za-z' ]+)\]\](?=[ ](?:(?:conjuration|conjured alteration spell)s?|or))", text, ): conjuration_stubs.add(stubify(match.group(1))) existing_conjurations = (session.query( Card.id, Card.stub, Card.name).filter(Card.stub.in_(conjuration_stubs), Card.is_legacy.is_(False)).all()) existing_stubs = set(x.stub for x in existing_conjurations) missing_conjurations = conjuration_stubs.symmetric_difference( existing_stubs) if missing_conjurations: raise MissingConjurations( f"The following conjurations must be added first: {', '.join([x for x in missing_conjurations])}" ) if copies is not None: card.copies = copies card.entity_id = create_entity(session) cost_list = re.split(r"\s+-\s+", cost) if isinstance(cost, str) else cost weight = 0 json_cost_list = [] if cost_list: for cost_entry in cost_list: split_cost = (re.split(r"\s+(?:/|or)\s+", cost_entry) if isinstance(cost_entry, str) else cost_entry) if len(split_cost) > 1: first_weight = parse_cost_to_weight(split_cost[0]) second_weight = parse_cost_to_weight(split_cost[1]) weight += max(first_weight, second_weight) json_cost_list.append(split_cost) else: weight += parse_cost_to_weight(split_cost[0]) json_cost_list.append(split_cost[0]) card.cost_weight = weight # Extract our effect costs into a list of strings and lists effect_cost_list = [] effect_costs = (re.split(r"\s+-\s+", effect_magic_cost) if isinstance( effect_magic_cost, str) else effect_magic_cost) if effect_costs: for cost_entry in effect_costs: split_cost = (re.split(r"\s+(?:/|or)\s+", cost_entry) if isinstance(cost_entry, str) else cost_entry) effect_cost_list.append(split_cost) if len( split_cost) > 1 else effect_cost_list.append(split_cost[0]) # Convert our cost lists into magicCost and effectMagicCost mappings json_magic_cost = parse_costs_to_mapping(json_cost_list) json_effect_cost = parse_costs_to_mapping(effect_cost_list) # And finally, convert our mappings into lists of required dice dice_set = set() alt_dice_set = set() for dice_type in list(json_magic_cost.keys()) + list( json_effect_cost.keys()): both_types = dice_type.split(" / ") if len(both_types) > 1: alt_dice_set.add(dice_name_from_cost(both_types[0])) alt_dice_set.add(dice_name_from_cost(both_types[1])) else: dice_set.add(dice_name_from_cost(both_types[0])) if dice is None: dice = list(dice_set) if alt_dice is None: alt_dice = list(alt_dice_set) card.dice_flags = Card.dice_to_flags(dice) card.alt_dice_flags = Card.dice_to_flags(alt_dice) json_data = { "name": card.name, "stub": card.stub, "type": card.card_type, "release": { "name": release.name, "stub": release.stub, }, } if existing_conjurations: json_data["conjurations"] = [{ "name": x.name, "stub": x.stub } for x in existing_conjurations] if placement: json_data["placement"] = placement if json_cost_list: json_data["cost"] = json_cost_list if dice: json_data["dice"] = dice if alt_dice: json_data["altDice"] = alt_dice if json_magic_cost: json_data["magicCost"] = json_magic_cost if json_effect_cost: json_data["effectMagicCost"] = json_effect_cost if text: json_data["text"] = text if phoenixborn is not None: json_data["phoenixborn"] = phoenixborn if attack is not None: json_data["attack"] = str_or_int(attack) if battlefield is not None: json_data["battlefield"] = str_or_int(battlefield) if life is not None: json_data["life"] = str_or_int(life) if recover is not None: json_data["recover"] = str_or_int(recover) if spellboard is not None: json_data["spellboard"] = str_or_int(spellboard) if copies is not None: json_data["copies"] = copies if can_effect_repeat: json_data["effectRepeats"] = True card.json = json_data session.add(card) session.commit() # Now that we have a card entry, we can populate the conjuration relationship(s) if existing_conjurations: for conjuration in existing_conjurations: session.add( CardConjuration(card_id=card.id, conjuration_id=conjuration.id)) session.commit() return card