class CardString(models.Model): id = models.AutoField(primary_key=True, serialize=False, verbose_name="ID") card = models.ForeignKey("Card", to_field="dbf_id", db_column="card_dbf_id", related_name="strings", on_delete=models.CASCADE) locale = IntEnumField(enum=enums.Locale, db_index=True) game_tag = IntEnumField(enum=enums.GameTag, db_index=True) value = models.TextField(blank=True) def __str__(self): return self.value
class Archetype(models.Model): """ Archetypes cluster decks with minor card variations that all share the same strategy into a common group. E.g. 'Freeze Mage', 'Miracle Rogue', 'Pirate Warrior', 'Zoolock', 'Control Priest' """ id = models.BigAutoField(primary_key=True) objects = ArchetypeManager() name = models.CharField(max_length=250, blank=True) player_class = IntEnumField(enum=enums.CardClass, default=enums.CardClass.INVALID) def get_canonical_decks(self, format=enums.FormatType.FT_STANDARD, as_of=None): if as_of is None: canonicals = self.canonical_decks.filter(format=format, ).order_by( "-created").prefetch_related("deck__includes").all() else: canonicals = self.canonical_decks.filter( format=format, created__lte=as_of).order_by( "-created").prefetch_related("deck__includes").all() if canonicals: return [c.deck for c in canonicals] else: return None def __str__(self): return self.name
class CanonicalDeck(models.Model): """ The CanonicalDeck for an Archetype is the list of cards that is most commonly associated with that Archetype. The canonical deck for an Archetype may evolve incrementally over time and is likely to evolve more rapidly when new card sets are first released. """ id = models.BigAutoField(primary_key=True) archetype = models.ForeignKey( Archetype, related_name="canonical_decks", on_delete=models.CASCADE ) deck = models.ForeignKey( Deck, related_name="canonical_for_archetypes", on_delete=models.PROTECT ) created = models.DateTimeField(auto_now_add=True) format = IntEnumField(enum=enums.FormatType, default=enums.FormatType.FT_STANDARD) class Meta: db_table = "cards_canonicaldeck"
class PegasusAccount(models.Model): id = models.BigAutoField(primary_key=True) account_hi = models.BigIntegerField( "Account Hi", help_text="The region value from account hilo" ) account_lo = models.BigIntegerField( "Account Lo", help_text="The account ID value from account hilo" ) region = IntEnumField(enum=BnetRegion) battletag = models.CharField(max_length=64, blank=True) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True ) created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) class Meta: unique_together = ("account_hi", "account_lo") def __str__(self): region = REGIONS.get(self.region, "Dev. region") return "%s - %s" % (self.battletag, region)
class CardTag(models.Model): id = models.AutoField(primary_key=True, serialize=False, verbose_name="ID") card = models.ForeignKey("Card", to_field="dbf_id", db_column="card_dbf_id", related_name="tags", on_delete=models.CASCADE) game_tag = IntEnumField(enum=enums.GameTag, db_index=True) value = models.PositiveIntegerField() def __str__(self): return f"{str(self.card)}.{self.game_tag.name}={str(self.value)}"
class Pack(models.Model): id = models.BigAutoField(primary_key=True) booster_type = IntEnumField(enum=Booster) date = models.DateTimeField() cards = models.ManyToManyField(Card, through="packs.PackCard") user = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) account_hi = models.BigIntegerField() account_lo = models.BigIntegerField() def __str__(self): cards = self.cards.all() if not cards: return "(Empty pack)" return ", ".join(str(card) for card in cards)
class Scenario(models.Model): id = models.AutoField(primary_key=True, serialize=False, verbose_name="ID") note_desc = models.CharField(max_length=64) players = models.PositiveSmallIntegerField() player1_hero_card_id = models.IntegerField(null=True) player2_hero_card_id = models.IntegerField(null=True) is_tutorial = models.BooleanField(default=False) is_expert = models.BooleanField(default=False) is_coop = models.BooleanField(default=False) adventure = models.ForeignKey(Adventure, models.SET_NULL, null=True, blank=True) wing = models.ForeignKey(Wing, models.SET_NULL, null=True, blank=True) sort_order = models.PositiveIntegerField(default=0) mode = IntEnumField(enum=AdventureMode, default=0) client_player2_hero_card_id = models.IntegerField(null=True) name = models.CharField(max_length=64) description = models.TextField() opponent_name = models.CharField(max_length=64) completed_description = models.TextField() player1_deck_id = models.IntegerField(null=True) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) build = models.PositiveIntegerField() dbf_columns = [ "ID", "NOTE_DESC", "PLAYERS", "PLAYER1_HERO_CARD_ID", "IS_TUTORIAL", "IS_EXPERT", "IS_COOP", "ADVENTURE_ID", "WING_ID", "SORT_ORDER", ("MODE_ID", "mode"), "CLIENT_PLAYER2_HERO_CARD_ID", "NAME", "DESCRIPTION", "OPPONENT_NAME", "COMPLETED_DESCRIPTION", "PLAYER1_DECK_ID", ] def __str__(self): return self.name or self.note_desc
class Signature(models.Model): id = models.AutoField(primary_key=True) archetype = models.ForeignKey( Archetype, on_delete=models.CASCADE ) format = IntEnumField(enum=enums.FormatType, default=enums.FormatType.FT_STANDARD) as_of = models.DateTimeField() def distance(self, deck): dist = 0 card_counts = {i.card: i.count for i in deck.includes.all()} for component in self.components.all(): if component.card in card_counts: dist += (card_counts[component.card] * component.weight) return dist
class Pack(models.Model): id = models.BigAutoField(primary_key=True) booster_type = IntEnumField(enum=Booster) date = models.DateTimeField() cards = models.ManyToManyField(Card, through="packs.PackCard") user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="packs") blizzard_account = models.ForeignKey("accounts.BlizzardAccount", on_delete=models.CASCADE, related_name="packs") def __str__(self): cards = self.cards.all() if not cards: return "(Empty pack)" return ", ".join(str(card) for card in cards)
class ArchetypeName(models.Model): id = models.BigAutoField(primary_key=True) objects = ArchetypeNameManager() name = models.CharField(max_length=250, blank=False) contributing_user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, blank=True, null=True) created = models.DateTimeField(default=timezone.now) player_class = IntEnumField(enum=enums.CardClass, default=enums.CardClass.INVALID) class Meta: # If only name had unique=True, users could submit wrong feedback (e.g. Zoolock on a # Warrior Deck) and the "Zoolock" name would be marked as WARRIOR for ever. unique_together = ("name", "player_class") def __str__(self): return self.name
class GameReplay(models.Model): """ Represents a replay as captured from the point of view of a single packet stream sent to a Hearthstone client. Replays can be uploaded by either of the players or by any number of spectators who watched the match. It is possible that the same game could be uploaded from multiple points of view. When this happens each GameReplay will point to the same GlobalGame record via the global_game foreign key. It is possible that different uploads of the same game will have different information in them. For example: - If Player A and Player B are Real ID Friends and Player C is Battle.net friends with just Player B, then when Player C spectates a match between Players A and B, his uploaded replay will show the BattleTag as the name of Player A. However if Player B uploads a replay of the same match, his replay will show the real name for Player A. - Likewise, if Player C either starts spectating the game after it has already begun or stops spectating before it ends, then his uploaded replay will have fewer turns of gameplay then Player B's replay. """ class Meta: # ordering = ("global_game", ) # Ordering on global_game causes nasty inner joins. # We order by descending ID instead for now, until we have an upload_date. ordering = ("-id", ) # Replays are unique to a perspective on the game (global_game): # - client_handle: the *same* client cant be used for multiple replays # - reconnecting: We do not currently unify games where the player reconnects # - friendly_player_id: Unique across a client_handle and a spectator_mode unique_together = ("global_game", "client_handle", "friendly_player_id", "spectator_mode", "reconnecting") id = models.BigAutoField(primary_key=True) shortid = ShortUUIDField("Short ID") upload_token = models.ForeignKey(AuthToken, on_delete=models.SET_NULL, null=True, blank=True, related_name="replays") user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="replays") global_game = models.ForeignKey( GlobalGame, on_delete=models.CASCADE, related_name="replays", help_text="References the single global game that this replay shows.") # The "friendly player" is the player whose cards are at the bottom of the # screen when watching a game. For spectators this is determined by which # player they started spectating first (if they spectate both). friendly_player_id = PlayerIDField( "Friendly PlayerID", null=True, help_text="PlayerID of the friendly player (1 or 2)", ) # When serving replay data in the security context of the user who uploaded this replay # this deck list should be used for the opponent, so as to avoid revealing unplayed cards # in the opponent's deck, which we might know in the case where multiple uploads were # unified. If the other uploader has set their replay visibility to private, then we cannot # leak the cards in their deck, via the globalgameplayer. opponent_revealed_deck = models.ForeignKey( "decks.Deck", on_delete=models.PROTECT, null=True, help_text="As much as is known of the opponent's starting deck list.") # This is useful to know because replays that are spectating both players # will have more data then those from a single player. # For example, they will have access to the cards that are in each players hand. # This is detectable from the raw logs, although we currently intend to have # the client uploading the replay provide it. spectator_mode = models.BooleanField(default=False) spectator_password = models.CharField("Spectator Password", max_length=16, blank=True) client_handle = models.IntegerField(null=True, blank=True) aurora_password = models.CharField(max_length=16, blank=True) build = models.PositiveIntegerField("Hearthstone Build", null=True, blank=True) replay_xml = models.FileField(upload_to=generate_upload_path) hsreplay_version = models.CharField( "HSReplay library version", max_length=16, help_text="The HSReplay library version used to generate the replay", ) hslog_version = models.CharField( "hslog library version", max_length=24, help_text="The python-hearthstone library version at processing", null=True, # TODO: Remove this once the table is clean of NULLs ) upload_ip = models.GenericIPAddressField(null=True, help_text="Uploader IP address") user_agent = models.CharField(max_length=100, null=True, help_text="Uploader User-Agent") # The fields below capture the preferences of the user who uploaded it. is_deleted = models.BooleanField( "Soft deleted", default=False, help_text="Indicates user request to delete the upload") won = models.NullBooleanField() disconnected = models.BooleanField(default=False) reconnecting = models.BooleanField( "Is reconnecting", default=False, help_text="Whether the player is reconnecting to an existing game", ) resumable = models.NullBooleanField() visibility = IntEnumField(enum=Visibility, default=Visibility.Public) hide_player_names = models.BooleanField(default=False) views = models.PositiveIntegerField(default=0) objects = GameReplayManager() def __str__(self): return str(self.global_game) @property def pretty_name(self): return self.build_pretty_name() @property def pretty_name_spoilerfree(self): return self.build_pretty_name(spoilers=False) @property def upload_event_admin_url(self): from hsreplaynet.uploads.models import UploadEvent # These get garbage collected periodically # So this will not exist for old games upload_event = UploadEvent.objects.filter(shortid=self.shortid) if upload_event.count() > 0: return upload_event.first().get_admin_url() else: return None def build_pretty_name(self, spoilers=True): players = self.global_game.players.values_list("player_id", "final_state", "name") if len(players) != 2: return "Broken game (%i players)" % (len(players)) if players[0][0] == self.friendly_player_id: friendly, opponent = players else: opponent, friendly = players if spoilers: if self.disconnected: state = "Disconnected" elif self.won: state = "Won" elif friendly[1] == opponent[1]: state = "Tied" else: state = "Lost" return "%s (%s) vs. %s" % (friendly[2], state, opponent[2]) return "%s vs. %s" % (friendly[2], opponent[2]) def get_absolute_url(self): return reverse("games_replay_view", kwargs={"id": self.shortid}) def generate_description(self): tpl = "Watch a game of Hearthstone between %s (%s) and %s (%s) in your browser." players = self.global_game.players.all() if len(players) != 2: return "" player1, player2 = players[0], players[1] return tpl % (player1, player1.hero.card_class.name.capitalize(), player2, player2.hero.card_class.name.capitalize()) def player(self, number): for player in self.global_game.players.all(): if player.player_id == number: return player @property def friendly_player(self): return self.global_game.players.get(player_id=self.friendly_player_id) @property def friendly_deck(self): return self.friendly_player.deck_list @property def opposing_player(self): return self.global_game.players.exclude( player_id=self.friendly_player_id).get() @property def opposing_deck(self): return self.opponent_revealed_deck @property def region(self): return self.friendly_player.pegasus_account.region def serialize(self): from .processing import get_replay_url from .serializers import GameReplaySerializer s = GameReplaySerializer(self) serialized = s.data serialized["url"] = get_replay_url(self.shortid) return serialized
class GlobalGame(models.Model): """ Represents a globally unique game (e.g. from the server's POV). The fields on this object represent information that is public to all players and spectators. When the same game is uploaded by multiple players or spectators they will all share a reference to a single global game. When a replay or raw log file is uploaded the server first checks for the existence of a GlobalGame record. It looks for any games that occured on the same region where both players have matching battle_net_ids and where the match start timestamp is within +/- 1 minute from the timestamp on the upload. The +/- range on the match start timestamp is to account for potential clock drift between the computer that generated this replay and the computer that uploaded the earlier record which first created the GlobalGame record. If no existing GlobalGame record is found, then one is created. """ id = models.BigAutoField(primary_key=True) # We believe game_id is not monotonically increasing as it appears # to roll over and reset periodically. game_handle = models.IntegerField( "Game handle", null=True, blank=True, help_text="Game ID on the Battle.net server") server_address = models.GenericIPAddressField(null=True, blank=True) server_port = models.IntegerField(null=True, blank=True) server_version = models.IntegerField(null=True, blank=True) build = models.PositiveIntegerField( null=True, blank=True, help_text="Hearthstone build number the game was played on.") match_start = models.DateTimeField(null=True, db_index=True) match_end = models.DateTimeField(null=True) game_type = IntEnumField("Game type", enum=BnetGameType, null=True, blank=True) format = IntEnumField("Format type", enum=FormatType, default=FormatType.FT_UNKNOWN) # ladder_season is nullable since not all games are ladder games ladder_season = models.IntegerField( "Ladder season", null=True, blank=True, help_text="The season as calculated from the match start timestamp.") # Nullable, since not all replays are TBs. # Will currently have no way to calculate this so it will always be null for now. brawl_season = models.IntegerField( "Tavern Brawl season", default=0, help_text= "The brawl season which increments every time the brawl changes.") # Nullable, We currently have no way to discover this. scenario_id = models.IntegerField( "Scenario ID", null=True, blank=True, db_index=True, help_text="ID from DBF/SCENARIO.xml or Scenario cache", ) # The following basic stats are globally visible to all num_turns = models.IntegerField(null=True, blank=True) num_entities = models.IntegerField(null=True, blank=True) tainted_decks = models.NullBooleanField() digest = models.CharField( max_length=40, unique=True, null=True, help_text= "SHA1 of str(game_handle), str(server_address), str(lo1), str(lo2)") loaded_into_redshift = models.DateTimeField(null=True) class Meta: ordering = ("-match_start", ) @property def exclude_from_statistics(self): return any( map(lambda r: r.user and r.user.exclude_from_statistics, self.replays.all())) def __str__(self): return " vs ".join(str(p) for p in self.players.all()) @property def duration(self): return self.match_end - self.match_start @property def is_ranked(self): return self.game_type in ( BnetGameType.BGT_RANKED_WILD, BnetGameType.BGT_RANKED_STANDARD, ) @property def is_casual(self): return self.game_type in ( BnetGameType.BGT_CASUAL_WILD, BnetGameType.BGT_CASUAL_STANDARD, ) @property def is_tavern_brawl(self): return self.game_type in ( BnetGameType.BGT_TAVERNBRAWL_PVP, BnetGameType.BGT_TAVERNBRAWL_1P_VERSUS_AI, BnetGameType.BGT_TAVERNBRAWL_2P_COOP, ) @property def num_own_turns(self): return ceil(self.num_turns / 2) @property def format_friendly_name(self): if self.is_ranked: if self.format == FormatType.FT_STANDARD: return "Ranked - Standard" elif self.format == FormatType.FT_WILD: return "Ranked - Wild" return "Ranked" elif self.is_casual: if self.format == FormatType.FT_STANDARD: return "Casual - Standard" elif self.format == FormatType.FT_WILD: return "Casual - Wild" return "Casual" elif self.is_tavern_brawl: return "Tavern Brawl" elif self.game_type == BnetGameType.BGT_ARENA: return "Arena" elif self.game_type == BnetGameType.BGT_FRIENDS: return "Friendly Match" return "" def acquire_redshift_lock(self): if not self.id: raise RuntimeError("Cannot claim lock on unsaved instances.") return acquire_redshift_lock([self.id]) def release_redshift_lock(self): if not self.id: raise RuntimeError("Cannot release lock on unsaved instances.") return release_redshift_lock([self.id])
class GlobalGamePlayer(models.Model): id = models.BigAutoField(primary_key=True) game = models.ForeignKey(GlobalGame, on_delete=models.CASCADE, related_name="players") name = models.CharField("Player name", blank=True, max_length=64, db_index=True) real_name = models.CharField("Real name", blank=True, max_length=64) player_id = PlayerIDField(blank=True) pegasus_account = models.ForeignKey("accounts.BlizzardAccount", on_delete=models.PROTECT) is_ai = models.BooleanField( "Is AI", default=False, help_text="Whether the player is an AI.", ) is_first = models.BooleanField( "Is first player", help_text="Whether the player is the first player", ) hero = models.ForeignKey(Card, on_delete=models.PROTECT) hero_premium = models.BooleanField( "Hero Premium", default=False, help_text="Whether the player's initial hero is golden.") final_state = IntEnumField( "Final State", enum=PlayState, default=PlayState.INVALID, ) extra_turns = models.SmallIntegerField( null=True, help_text="Extra turns taken by the player this game (Time Warp).") deck_list = models.ForeignKey( "decks.Deck", on_delete=models.PROTECT, help_text="As much as is known of the player's starting deck list.") # Game type metadata rank = models.SmallIntegerField( "Rank", null=True, blank=True, help_text="1 through 25, or 0 for legend.", ) legend_rank = models.PositiveIntegerField(null=True, blank=True) stars = models.PositiveSmallIntegerField(null=True, blank=True) wins = models.PositiveIntegerField( "Wins", null=True, blank=True, help_text= "Number of wins in the current game mode (eg. ladder season, arena key...)", ) losses = models.PositiveIntegerField( "Losses", null=True, blank=True, help_text="Number of losses in the current game mode (current season)", ) # Legacy deck id, mistakenly defined as 32-bit deck_id_legacy = models.IntegerField("Deck ID (OLD)", null=True, blank=True) deck_id = models.BigIntegerField("Deck ID", null=True, blank=True) cardback_id = models.IntegerField("Cardback ID", null=True, blank=True) class Meta: unique_together = ("game", "player_id") def __str__(self): return self.name or self.real_name @property def won(self): return self.final_state in (PlayState.WINNING, PlayState.WON) @property def opponent(self): opponent_id = 2 if self.player_id == 1 else 2 try: player = GlobalGamePlayer.objects.get(game=self.game_id, player_id=opponent_id) except GlobalGamePlayer.DoesNotExist: player = None return player @property def hero_class_name(self): return self.hero.card_class.name
class Scenario(models.Model): note_desc = models.CharField(max_length=64) players = models.PositiveSmallIntegerField() player1_hero_card_id = models.IntegerField(null=True) player2_hero_card_id = models.IntegerField(null=True) is_tutorial = models.BooleanField(default=False) is_expert = models.BooleanField(default=False) is_coop = models.BooleanField(default=False) adventure = models.ForeignKey(Adventure, models.SET_NULL, null=True, blank=True) wing = models.ForeignKey(Wing, models.SET_NULL, null=True, blank=True) sort_order = models.PositiveIntegerField(default=0) mode = IntEnumField(enum=AdventureMode, default=0) client_player2_hero_card_id = models.IntegerField(null=True) name = models.CharField(max_length=64) description = models.TextField() opponent_name = models.CharField(max_length=64) completed_description = models.TextField() player1_deck_id = models.IntegerField(null=True) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) build = models.PositiveIntegerField() dbf_columns = [ "ID", "NOTE_DESC", "PLAYERS", "PLAYER1_HERO_CARD_ID", "IS_TUTORIAL", "IS_EXPERT", "IS_COOP", "ADVENTURE_ID", "WING_ID", "SORT_ORDER", ("MODE_ID", "mode"), "CLIENT_PLAYER2_HERO_CARD_ID", "NAME", "DESCRIPTION", "OPPONENT_NAME", "COMPLETED_DESCRIPTION", "PLAYER1_DECK_ID", ] def __str__(self): return self.name or self.note_desc @staticmethod def ai_deck_list(scenario_id): """Return the AIs card list as determined across all games played.""" deck = defaultdict(int) # Only examine this many games to make it perform faster sample_size = 20 replays = GameReplay.objects.filter( global_game__scenario_id=scenario_id)[:sample_size] for replay in replays: for include in replay.opposing_player.deck_list.includes.all(): card = include.card current_count = deck[card] if include.count > current_count: deck[card] = include.count alpha_sorted = sorted(deck.keys(), key=lambda c: c.name) mana_sorted = sorted(alpha_sorted, key=lambda c: c.cost) result = [] for card in mana_sorted: for i in range(0, deck[card]): result.append(card) return result @staticmethod def winning_decks(scenario_id): """ Returns a list like: [ { "deck": <Deck>, "num_wins": 23, # These are sorted in fastest order "fastest_wins": [<Replay>, <Replay>, ...] }, { "deck": <Deck>, "num_wins": 19, # These are sorted in fastest order "fastest_wins": [<Replay>, <Replay>, ...] }, ... ] The top level list elements are sorted by the deck with the most wins, and the "fastest_wins" element is sorted in order of the wins which took the least number of turns. """ complete_replays = [] for replay in GameReplay.objects.filter( global_game__scenario_id=scenario_id).all(): if replay.friendly_player.final_state == PlayState.WON: if replay.friendly_player.deck_list.size == 30: complete_replays.append(replay) all_decks = defaultdict(dict) # Sort all the examples by match start so that the first example # of a win is selected if there are many. for replay in sorted(complete_replays, key=lambda r: r.global_game.match_start): current_winning_deck = all_decks[replay.friendly_player.deck_list] if "num_wins" in current_winning_deck: current_winning_deck["num_wins"] += 1 else: current_winning_deck["num_wins"] = 1 if "fastest_wins" in current_winning_deck: fastest_wins = current_winning_deck["fastest_wins"] else: fastest_wins = defaultdict(list) current_winning_deck["fastest_wins"] = fastest_wins fastest_wins[replay.global_game.num_turns].append(replay) result = [] for deck, meta in sorted(all_decks.items(), key=lambda t: t[1]["num_wins"], reverse=True): current_result = { "deck": deck, "num_wins": meta["num_wins"], "fastest_wins": [] } for turns, replays in sorted(meta["fastest_wins"].items(), key=lambda t: t[0]): current_result["fastest_wins"].extend(replays) result.append(current_result) return result
class Webhook(models.Model): uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) endpoint = models.ForeignKey(WebhookEndpoint, null=True, on_delete=models.SET_NULL, related_name="webhooks") url = models.URLField() event = models.ForeignKey(Event, null=True, on_delete=models.SET_NULL, related_name="webhooks") payload = JSONField(encoder=DjangoJSONEncoder) status = IntEnumField(enum=WebhookStatus, default=WebhookStatus.UNKNOWN) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) def __str__(self): return "%s -> %s" % (self.event, self.url) def schedule_delivery(self): """ Schedule the webhook for delivery. On ENV_AWS, this schedules a Lambda trigger. Otherwise, triggers immediately. """ self.status = WebhookStatus.PENDING self.save() if settings.WEBHOOKS["USE_LAMBDA"]: from hsreplaynet.utils.aws.clients import LAMBDA LAMBDA.invoke( FunctionName="trigger_webhook", InvocationType="Event", Payload=json.dumps({"webhook": str(self.pk)}), ) else: self.deliver() def deliver(self): if not self.endpoint: raise ForbiddenWebhookDelivery( "Cannot deliver a webhook with no endpoint.") if self.status != WebhookStatus.PENDING: raise ForbiddenWebhookDelivery("Not triggering for status %r" % (self.status)) self.status = WebhookStatus.IN_PROGRESS self.save() secret = str(self.endpoint.secret).encode("utf-8") body = json.dumps(self.payload, cls=DjangoJSONEncoder).encode("utf-8") signature = generate_signature(secret, body) default_headers = { "content-type": WEBHOOK_CONTENT_TYPE, "user-agent": WEBHOOK_USER_AGENT, "x-webhook-signature": signature, } session = Session() request = session.prepare_request( Request("POST", self.url, headers=default_headers, data=body)) delivery = WebhookDelivery(webhook=self, url=request.url, request_headers=dict(request.headers), request_body=body) begin = time.time() try: response = session.send(request, allow_redirects=False, timeout=self.endpoint.timeout) except Exception as e: delivery.success = False delivery.error = str(e) delivery.traceback = traceback.format_exc() delivery.response_headers = {} delivery.response_body = "" self.status = WebhookStatus.ERROR else: delivery.success = 200 <= response.status_code <= 299 delivery.response_status = response.status_code delivery.response_headers = dict(response.headers) delivery.response_body = response.text[:RESPONSE_BODY_MAX_SIZE] self.status = WebhookStatus.SUCCESS delivery.completed_time = int((time.time() - begin) * 1000) delivery.save() self.save()
class ClusterSetSnapshot(models.Model, ClusterSet): id = models.AutoField(primary_key=True) objects = ClusterSetManager() as_of = models.DateTimeField(default=timezone.now) game_format = IntEnumField(enum=enums.FormatType, default=enums.FormatType.FT_STANDARD) live_in_production = models.BooleanField(default=False) promoted_on = models.DateTimeField(null=True) latest = models.BooleanField(default=False) training_run_id = models.IntegerField(null=True, blank=True) CLASS_CLUSTER_FACTORY = ClassClusterSnapshot CLUSTER_FACTORY = ClusterSnapshot class Meta: get_latest_by = "as_of" def __str__(self): return ClusterSet.__str__(self) @property def class_clusters(self): if not hasattr(self, "_class_clusters"): self._class_clusters = list(self.classclustersnapshot_set.all()) return self._class_clusters @class_clusters.setter def class_clusters(self, cc): self._class_clusters = cc for class_cluster in cc: class_cluster.cluster_set = self @property def cluster_set_key_prefix(self): template = "models/{game_format}/{cluster_set_id}/{run_id}/" return template.format( game_format=self.game_format.name, cluster_set_id=self.id, run_id=self.training_run_id, ) def update_all_signatures(self): for class_cluster in self.class_clusters: class_cluster.update_cluster_signatures() for cluster in class_cluster.clusters: cluster.save() def update_archetype_signatures(self, force=False): if force or all(c.neural_network_ready() for c in self.class_clusters): with transaction.atomic(): ClusterSetSnapshot.objects.filter( live_in_production=True).update(live_in_production=False) self.live_in_production = True self.promoted_on = now() self.save() self.synchronize_deck_archetype_assignments() else: msg = "Cannot promote to live=True because the neural network is not ready" raise RuntimeError(msg) def synchronize_deck_archetype_assignments(self): for_update = collections.defaultdict(list) for class_cluster in self.class_clusters: for cluster in class_cluster.clusters: if cluster.external_id and cluster.external_id != -1: for data_point in cluster.data_points: digest = Deck.objects.get_digest_from_shortid( data_point["shortid"]) for_update[cluster.external_id].append(digest) for external_id, digests in for_update.items(): deck_ids = Deck.objects.filter(digest__in=digests).values_list( "id", flat=True) Deck.objects.bulk_update_to_archetype(deck_ids, external_id) def train_neural_network(self, num_examples=1000000, max_dropped_cards=15, stratified=False, min_cards_for_determination=5, batch_size=1000, num_epochs=20, base_layer_size=64, hidden_layer_size=64, num_hidden_layers=2, working_dir=None, upload_to_s3=False, included_classes=None): start_ts = time.time() run_id = int(start_ts) self.training_run_id = run_id self.save() if working_dir: training_dir = working_dir else: training_dir = os.path.join(settings.BUILD_DIR, "models", str(run_id)) if not os.path.exists(training_dir): os.mkdir(training_dir) summary_path = os.path.join(training_dir, "summary.txt") with open(summary_path, "w") as summary: summary.write("Game Format: %s\n" % self.game_format.name) summary.write("Cluster Set As Of: %s\n" % self.as_of.isoformat()) summary.write("Training Run: %i\n\n" % run_id) summary.write("Num Examples: %i\n" % num_examples) summary.write("Max Dropped Cards: %i\n" % max_dropped_cards) summary.write("Stratified: %s\n" % str(stratified)) summary.write("Min Cards For Determination: %i\n" % min_cards_for_determination) summary.write("Batch Size: %i\n" % batch_size) summary.write("Num Epochs: %i\n" % num_epochs) summary.write("Base Layer Size: %i\n" % base_layer_size) summary.write("Hidden Layer Size: %i\n" % hidden_layer_size) summary.write("Num Hidden Layers: %i\n\n" % num_hidden_layers) for class_cluster in self.class_clusters: player_class_name = class_cluster.player_class.name if included_classes and player_class_name not in included_classes: continue print("\nInitiating training for %s" % class_cluster.player_class.name) training_start = time.time() accuracy = class_cluster.train_neural_network( num_examples=num_examples, max_dropped_cards=max_dropped_cards, stratified=stratified, min_cards_for_determination=min_cards_for_determination, batch_size=batch_size, num_epochs=num_epochs, base_layer_size=base_layer_size, hidden_layer_size=hidden_layer_size, num_hidden_layers=num_hidden_layers, working_dir=training_dir, upload_to_s3=upload_to_s3) training_stop = time.time() duration = int(training_stop - training_start) print("Duration: %s seconds" % duration) print("Accuracy: %s" % round(accuracy, 4)) summary.write("%s Duration: %i seconds\n" % (player_class_name, duration)) summary.write("%s Accuracy: %s\n\n" % (player_class_name, round(accuracy, 4))) end_ts = time.time() full_duration = end_ts - start_ts duration_mins = int(full_duration / 60) duration_secs = int(full_duration % 60) summary.write("Full Duration: %i min(s) %i seconds\n" % (duration_mins, duration_secs)) if upload_to_s3 and os.path.exists(summary_path): with open(summary_path, "rb") as summary: S3.put_object(Bucket=settings.KERAS_MODELS_BUCKET, Key=self.cluster_set_key_prefix + "summary.txt", Body=summary)
class ClassClusterSnapshot(models.Model, ClassClusters): id = models.AutoField(primary_key=True) cluster_set = models.ForeignKey("ClusterSetSnapshot", on_delete=models.CASCADE) player_class = IntEnumField(enum=enums.CardClass, default=enums.CardClass.INVALID) def __str__(self): return ClassClusters.__str__(self) @property def clusters(self): if not hasattr(self, "_clusters"): self._clusters = list(self.clustersnapshot_set.all()) return self._clusters @clusters.setter def clusters(self, clusters): self._clusters = clusters for cluster in clusters: cluster.class_cluster = self def _fetch_training_data(self, num_examples=1000000, max_dropped_cards=15, stratified=False, min_cards_for_determination=5): from hsarchetypes.features import to_neural_net_training_data key = (self.id, num_examples, max_dropped_cards, stratified, min_cards_for_determination) if key not in _TRAINING_DATA_CACHE: print("Constructing new training data: %s" % str(key)) _TRAINING_DATA_CACHE[key] = to_neural_net_training_data( self, num_examples=num_examples, max_dropped_cards=max_dropped_cards, stratified=stratified, min_cards_for_determination=min_cards_for_determination) else: print("Serving data from cache: %s" % str(key)) return _TRAINING_DATA_CACHE[key] def train_neural_network(self, num_examples=1000000, max_dropped_cards=15, stratified=False, min_cards_for_determination=5, batch_size=1000, num_epochs=20, base_layer_size=64, hidden_layer_size=64, num_hidden_layers=2, working_dir=None, upload_to_s3=False): from hsarchetypes.classification import train_neural_net from hsarchetypes.utils import plot_accuracy_graph, plot_loss_graph common_prefix_template = "%s_%i_%i_%s" values = ( self.cluster_set.game_format.name, self.cluster_set.id, self.cluster_set.training_run_id, self.player_class.name, ) common_prefix = common_prefix_template % values full_model_path = os.path.join(working_dir, common_prefix + "_model.h5") train_x, train_Y = self._fetch_training_data( num_examples=num_examples, max_dropped_cards=max_dropped_cards, stratified=stratified, min_cards_for_determination=min_cards_for_determination) print("Finished generating training data") history = train_neural_net(train_x, train_Y, full_model_path, batch_size=batch_size, num_epochs=num_epochs, base_layer_size=base_layer_size, hidden_layer_size=hidden_layer_size, num_hidden_layers=num_hidden_layers) accuracy = history.history["val_acc"][-1] * 100 vals = (self.player_class.name, accuracy) print("%s accuracy: %.2f%%\n" % vals) loss_file_path = os.path.join(working_dir, common_prefix + "_loss.png") plot_loss_graph(history, self.player_class.name, loss_file_path) accuracy_file_path = os.path.join(working_dir, common_prefix + "_accuracy.png") plot_accuracy_graph(history, self.player_class.name, accuracy_file_path) if upload_to_s3: # The key structure for models in the bucket is as follows: # /models/<game_format>/<cluster_set_id>/<run_id>/<player_class>.h5 # Which allows for easy listing of all the run_ids for a given snapshot # Within each run_id folder we expect: # A <player_class>.h5 file for each class # A summary.txt # A <player_class>_accuracy.png # A <player_class>_loss.png if os.path.exists(full_model_path): with open(full_model_path, "rb") as model: S3.put_object(Bucket=settings.KERAS_MODELS_BUCKET, Key=self.model_key, Body=model) if os.path.exists(loss_file_path): with open(loss_file_path, "rb") as model: S3.put_object(Bucket=settings.KERAS_MODELS_BUCKET, Key=self.loss_graph_key, Body=model) if os.path.exists(accuracy_file_path): with open(accuracy_file_path, "rb") as model: S3.put_object(Bucket=settings.KERAS_MODELS_BUCKET, Key=self.accuracy_graph_key, Body=model) return accuracy def predict_archetype_id(self, deck): event = self._to_prediction_event(deck) if settings.USE_ARCHETYPE_PREDICTION_LAMBDA or settings.ENV_AWS: with influx_timer("callout_to_predict_deck_archetype"): response = LAMBDA.invoke( FunctionName="predict_deck_archetype", InvocationType="RequestResponse", # Synchronous invocation Payload=json.dumps(event), ) if response[ "StatusCode"] == 200 and "FunctionError" not in response: result = json.loads( response["Payload"].read().decode("utf8")) else: raise RuntimeError( response["Payload"].read().decode("utf8")) else: from keras_handler import handler result = handler(event, None) predicted_class = result["predicted_class"] id_encoding = self.one_hot_external_ids(inverse=True) predicted_archetype_id = id_encoding[predicted_class] if predicted_archetype_id == -1: return None else: return predicted_archetype_id def _to_prediction_event(self, deck): from hsarchetypes.utils import to_prediction_vector_from_dbf_map prediction_vector = to_prediction_vector_from_dbf_map(deck.dbf_map()) return { "model_bucket": settings.KERAS_MODELS_BUCKET, "model_key": self.model_key, "deck_vector": json.dumps(prediction_vector) } def neural_network_ready(self): return s3_object_exists(settings.KERAS_MODELS_BUCKET, self.model_key) @property def loss_graph_key(self): return self.common_key_prefix + "-loss.png" @property def accuracy_graph_key(self): return self.common_key_prefix + "-accuracy.png" @property def model_key(self): return self.common_key_prefix + ".h5" @property def common_key_prefix(self): return self.cluster_set.cluster_set_key_prefix + self.player_class.name
class Archetype(models.Model): """ Archetypes cluster decks with minor card variations that all share the same strategy into a common group. E.g. 'Freeze Mage', 'Miracle Rogue', 'Pirate Warrior', 'Zoolock', 'Control Priest' """ id = models.BigAutoField(primary_key=True) objects = ArchetypeManager() name = models.CharField(max_length=250, blank=True) player_class = IntEnumField(enum=enums.CardClass, default=enums.CardClass.INVALID) deleted = models.BooleanField(default=False) class Meta: db_table = "cards_archetype" def __str__(self): return self.name @property def promoted_clusters(self): return ClusterSnapshot.objects.filter( class_cluster__player_class=self.player_class, external_id=self.id).exclude( class_cluster__cluster_set__promoted_on=None).order_by( "-class_cluster__cluster_set__promoted_on") @property def standard_cluster(self): return ClusterSnapshot.objects.get_live_cluster_for_archetype( enums.FormatType.FT_STANDARD, self) @property def wild_cluster(self): return ClusterSnapshot.objects.get_live_cluster_for_archetype( enums.FormatType.FT_WILD, self) @property def sankey_visualization(self): return self.standard_cluster.sankey_visualization() @property def standard_signature(self): return self.get_signature(enums.FormatType.FT_STANDARD) @property def wild_signature(self): return self.get_signature(enums.FormatType.FT_WILD) @property def standard_ccp_signature(self): return self.get_signature(enums.FormatType.FT_STANDARD, True) @property def wild_ccp_signature(self): return self.get_signature(enums.FormatType.FT_WILD, True) def get_signature(self, game_format, use_ccp=False): cluster = self.promoted_clusters.filter( class_cluster__cluster_set__game_format=game_format).first() if not cluster: return {} signature = cluster.ccp_signature if use_ccp else cluster.signature return { "as_of": cluster.class_cluster.cluster_set.as_of, "format": int(cluster.class_cluster.cluster_set.game_format), "components": [(int(dbf_id), weight) for dbf_id, weight in signature.items()], } @property def standard_signature_pretty(self): cluster = self.standard_cluster if cluster: return cluster.pretty_signature_string() return "" @property def standard_ccp_signature_pretty(self): cluster = self.standard_cluster if cluster: return cluster.pretty_ccp_signature_string() return "" @property def wild_signature_pretty(self): cluster = self.wild_cluster if cluster: return cluster.pretty_signature_string() else: return "" @property def wild_signature_as_of(self): cluster = self.wild_cluster if cluster: return cluster.class_cluster.cluster_set.as_of @property def standard_signature_as_of(self): cluster = self.standard_cluster if cluster: return cluster.class_cluster.cluster_set.as_of def get_absolute_url(self): return reverse("archetype_detail", kwargs={ "id": self.id, "slug": slugify(self.name) })
class Feature(models.Model): """ A Feature is any logical chunk of functionality whose visibility should be determined at runtime based on the context of the current user. The status of a feature and the rules that govern whether it is rendered are determined by the FeatureStatus enum that is defined above. The read_only flag is a flag that always defaults to False but can be set true temporarily when maintenance needs to be done to the database. In order for this to work, ALL operations that write to or mutate the database in any way must be wrapped in a feature tag or a feature decorator, and they must implement graceful behavior and feedback to users when they are operating in read_only mode. For version 2 of a released feature, a new feature tag should be created that is named appropriately to indicate it is a new version. It's status lifecycle should be managed in the usual way for new features, independently of the previous version's status. """ name = models.CharField(max_length=255, null=False, unique=True) description = models.TextField(blank=True) created = models.DateTimeField(auto_now_add=True) status = IntEnumField(enum=FeatureStatus, default=FeatureStatus.OFF) read_only = models.BooleanField(default=False) rollout_percent = models.PositiveIntegerField( default=100, validators=[MaxValueValidator(100)], help_text=( "This may be set to a value lower than 100 to further restrict " "the user pool with access to this feature.")) def __str__(self): return self.name def is_user_part_of_rollout(self, user) -> bool: if user.is_authenticated: base = user.pk else: base = id(user) offset = int(self.created.microsecond / 10000) return (base + offset) % 100 <= self.rollout_percent def enabled_for_user(self, user) -> bool: if self.status == FeatureStatus.OFF: return False if self.status == FeatureStatus.STAFF_ONLY: return user.is_superuser or user.is_staff if self.status == FeatureStatus.AUTHORIZED_ONLY: return user.groups.filter(name=self.authorized_group_name).exists() if not self.is_user_part_of_rollout(user): return user.groups.filter(name=self.authorized_group_name).exists() if self.status == FeatureStatus.LOGGED_IN_USERS: return user.is_authenticated if self.status == FeatureStatus.PUBLIC: return True return False def add_user_to_authorized_group(self, user) -> bool: group = self.authorized_group if group not in user.groups.all(): user.groups.add(self.authorized_group) return True return False def remove_user(self, user) -> bool: group = self.authorized_group if group in user.groups.all(): user.groups.remove(self.authorized_group) return True return False @property def authorized_group_name(self) -> str: return f"feature:{self.name}:preview" @property def authorized_group(self): return Group.objects.get(name=self.authorized_group_name)
class Card(models.Model): card_id = models.CharField(primary_key=True, max_length=50) dbf_id = models.IntegerField(null=True, unique=True, db_index=True) name = models.CharField(max_length=64) description = models.TextField(blank=True) flavortext = models.TextField(blank=True) how_to_earn = models.TextField(blank=True) how_to_earn_golden = models.TextField(blank=True) artist = models.CharField(max_length=255, blank=True) card_class = IntEnumField(enum=enums.CardClass, default=enums.CardClass.INVALID) card_set = IntEnumField(enum=enums.CardSet, default=enums.CardSet.INVALID) faction = IntEnumField(enum=enums.Faction, default=enums.Faction.INVALID) race = IntEnumField(enum=enums.Race, default=enums.Race.INVALID) rarity = IntEnumField(enum=enums.Rarity, default=enums.Rarity.INVALID) type = IntEnumField(enum=enums.CardType, default=enums.CardType.INVALID) multi_class_group = IntEnumField( enum=enums.MultiClassGroup, default=enums.MultiClassGroup.INVALID, ) collectible = models.BooleanField(default=False) battlecry = models.BooleanField(default=False) divine_shield = models.BooleanField(default=False) deathrattle = models.BooleanField(default=False) elite = models.BooleanField(default=False) evil_glow = models.BooleanField(default=False) inspire = models.BooleanField(default=False) forgetful = models.BooleanField(default=False) one_turn_effect = models.BooleanField(default=False) poisonous = models.BooleanField(default=False) ritual = models.BooleanField(default=False) secret = models.BooleanField(default=False) taunt = models.BooleanField(default=False) topdeck = models.BooleanField(default=False) atk = models.IntegerField(default=0) health = models.IntegerField(default=0) durability = models.IntegerField(default=0) cost = models.IntegerField(default=0) windfury = models.IntegerField(default=0) spare_part = models.BooleanField(default=False) overload = models.IntegerField(default=0) spell_damage = models.IntegerField(default=0) craftable = models.BooleanField(default=False) objects = models.Manager() includibles = IncludibleCardManager() class Meta: db_table = "card" @property def id(self): return self.card_id @classmethod def from_cardxml(cls, card, save=False): obj = cls(card_id=card.id) obj.update_from_cardxml(card, save=save) return obj @classmethod def get_string_id(cls, dbf_id): if not DBF_DB: db, _ = cardxml.load_dbf() DBF_DB.update(db) return DBF_DB[dbf_id].id def __str__(self): return self.name @property def slug(self): return slugify(self.name) def get_absolute_url(self): # XXX return reverse("card_detail", kwargs={ "pk": self.dbf_id, "slug": self.slug }) def get_card_art_url(self, resolution=256, format="jpg"): return "https://art.hearthstonejson.com/v1/%ix/%s.%s" % ( resolution, self.id, format) def get_card_render_url(self, resolution=256, format="png", locale="enUS", build="latest"): return "https://art.hearthstonejson.com/v1/render/%s/%s/%ix/%s.%s" % ( build, locale, resolution, self.id, format) def get_tile_url(self, format="png"): return "https://art.hearthstonejson.com/v1/tiles/%s.%s" % (self.id, format) def localized_name(self, locale) -> str: try: return self.strings.get(locale=locale, game_tag=enums.GameTag.CARDNAME).value except CardString.DoesNotExist: return "" def update_from_cardxml(self, cardxml, save=False): for k in dir(cardxml): if k.startswith("_") or k in ("id", "tags", "strings"): continue # Transfer all existing CardXML attributes to our model if hasattr(self, k): setattr(self, k, getattr(cardxml, k)) if save: self.save() @property def playable(self): """Returns whether the card can be played.""" if self.type in [ enums.CardType.MINION, enums.CardType.SPELL, enums.CardType.WEAPON ]: return True # Heroes can only be played if they are not the basic heroes or hero skins. if self.type == enums.CardType.HERO: return self.card_set not in [ enums.CardSet.CORE, enums.CardSet.HERO_SKINS ] return False @property def includible(self): return self.collectible and self.playable
class Card(models.Model): id = models.CharField(primary_key=True, max_length=50) dbf_id = models.IntegerField(null=True, unique=True, db_index=True) objects = CardManager() name = models.CharField(max_length=50) description = models.TextField(blank=True) flavortext = models.TextField(blank=True) how_to_earn = models.TextField(blank=True) how_to_earn_golden = models.TextField(blank=True) artist = models.CharField(max_length=255, blank=True) card_class = IntEnumField(enum=enums.CardClass, default=enums.CardClass.INVALID) card_set = IntEnumField(enum=enums.CardSet, default=enums.CardSet.INVALID) faction = IntEnumField(enum=enums.Faction, default=enums.Faction.INVALID) race = IntEnumField(enum=enums.Race, default=enums.Race.INVALID) rarity = IntEnumField(enum=enums.Rarity, default=enums.Rarity.INVALID) type = IntEnumField(enum=enums.CardType, default=enums.CardType.INVALID) collectible = models.BooleanField(default=False) battlecry = models.BooleanField(default=False) divine_shield = models.BooleanField(default=False) deathrattle = models.BooleanField(default=False) elite = models.BooleanField(default=False) evil_glow = models.BooleanField(default=False) inspire = models.BooleanField(default=False) forgetful = models.BooleanField(default=False) one_turn_effect = models.BooleanField(default=False) poisonous = models.BooleanField(default=False) ritual = models.BooleanField(default=False) secret = models.BooleanField(default=False) taunt = models.BooleanField(default=False) topdeck = models.BooleanField(default=False) atk = models.IntegerField(default=0) health = models.IntegerField(default=0) durability = models.IntegerField(default=0) cost = models.IntegerField(default=0) windfury = models.IntegerField(default=0) spare_part = models.BooleanField(default=False) overload = models.IntegerField(default=0) spell_damage = models.IntegerField(default=0) craftable = models.BooleanField(default=False) class Meta: db_table = "card" @classmethod def from_cardxml(cls, card, save=False): obj = cls(id=card.id) obj.update_from_cardxml(card, save=save) return obj def __str__(self): return self.name @property def slug(self): return slugify(self.name) def get_absolute_url(self): return reverse("card_detail", kwargs={ "pk": self.dbf_id, "slug": self.slug }) def update_from_cardxml(self, cardxml, save=False): for k in dir(cardxml): if k.startswith("_"): continue # Transfer all existing CardXML attributes to our model if hasattr(self, k): setattr(self, k, getattr(cardxml, k)) if save: self.save()
class SampleModel(models.Model): int_field = IntEnumField(enum=SampleEnum) null_field = IntEnumField(enum=SampleEnum, null=True)
class Feature(models.Model): """ A Feature is any logical chunk of functionality whose visibility should be determined at runtime based on the context of the current user. The status of a feature and the rules that govern whether it is rendered are determined by the FeatureStatus enum that is defined above. The read_only flag is a flag that always defaults to False but can be set true temporarily when maintenance needs to be done to the database. In order for this to work, ALL operations that write to or mutate the database in any way must be wrapped in a feature tag or a feature decorator, and they must implement graceful behavior and feedback to users when they are operating in read_only mode. For version 2 of a released feature, a new feature tag should be created that is named appropriately to indicate it is a new version. It's status lifecycle should be managed in the usual way for new features, independently of the previous version's status. """ name = models.CharField(max_length=255, null=False, unique=True) description = models.TextField(blank=True) created = models.DateTimeField(auto_now_add=True) status = IntEnumField(enum=FeatureStatus, default=FeatureStatus.OFF) read_only = models.BooleanField(default=False) def __str__(self): return self.name def enabled_for_user(self, user): if self.status == FeatureStatus.OFF: return False if user.is_staff: # Staff can always see everything except OFF return True if self.status == FeatureStatus.STAFF_ONLY: # If the user is staff we will have already returned True return False if self.status == FeatureStatus.AUTHORIZED_ONLY: return user.groups.filter(name=self.authorized_group_name).exists() if self.status == FeatureStatus.LOGGED_IN_USERS: return user.is_authenticated if self.status == FeatureStatus.PUBLIC: return True def add_user_to_authorized_group(self, user): group = self.authorized_group if group not in user.groups.all(): user.groups.add(self.authorized_group) return True @property def authorized_group_name(self): return "feature:%s:preview" % self.name @property def authorized_group(self): return Group.objects.get(name=self.authorized_group_name)