···11+"""Package with all the implementation for execute a Secret Santa game."""
22+33+from pydantic_settings import BaseSettings, SettingsConfigDict
44+from pydantic import SecretStr
55+66+77+class Settings(BaseSettings):
88+ """Configuration for the secret santa game."""
99+1010+ # email notification defaults
1111+ default_notification_from: str = "Amigo Invisible <amigoinvisible@mgabarda.com>"
1212+ default_notification_subject: str = "Sorteo Amigo Invisible"
1313+1414+ # mailgun settings
1515+ mailgun_api_url: str
1616+ mailgun_api_key: SecretStr
1717+1818+ # debug
1919+ debug: bool = False
2020+ debug_to_email: str | None = None
2121+2222+ # attempts limit
2323+ limit: int = 30
2424+2525+ model_config = SettingsConfigDict(
2626+ env_prefix="santa_", env_file=".env", env_file_encoding="utf-8"
2727+ )
2828+2929+3030+settings = Settings() # type: ignore
+132
santa/draws.py
···11+"""This module contains the logic of the draw."""
22+33+import random
44+import logging
55+66+from . import settings
77+88+logger = logging.getLogger(__name__)
99+1010+1111+class Draw:
1212+ """This class represents the secret santa draw, handling the participants as a list
1313+ of strings.
1414+ """
1515+1616+ # list of all the participants
1717+ participants: list[str]
1818+1919+ # current solution status, a list of pairs of participants
2020+ solution: list[tuple[str, str]]
2121+2222+ # available participants
2323+ available: set[str]
2424+2525+ # list of pair of participants that can't be assigned
2626+ exclusions: list[tuple[str, str]]
2727+2828+ def __init__(
2929+ self,
3030+ participants: list[str],
3131+ exclusions: list[tuple[str, str]] | None = None,
3232+ ):
3333+ """Initializes the draw."""
3434+ self.participants = participants
3535+ self.solution = []
3636+ self.available = set(participants)
3737+ self.exclusions = exclusions or []
3838+3939+ def __len__(self) -> int:
4040+ """The len of the draw is the len of the current solution."""
4141+ return len(self.solution)
4242+4343+ def is_complete(self) -> bool:
4444+ """Draw is complete when the solution covers all the nodes, except for the
4545+ transition that completes the cycle.
4646+ """
4747+ return len(self.solution) == len(self.participants) - 1
4848+4949+ def is_valid(self, transition: tuple[str, str]) -> bool:
5050+ """A transition is valid if it is not in the exclusion list and not in the
5151+ current solution.
5252+ """
5353+ return transition not in self.exclusions and transition not in self.solution
5454+5555+ def closing_transition(self) -> tuple[str, str]:
5656+ """Creates the closing transition."""
5757+ return (self.solution[-1][1], self.solution[0][0])
5858+5959+ def pick(self) -> str:
6060+ """Selects the next possible participant."""
6161+ if not self.solution:
6262+ selected = random.choice(list(self.available))
6363+ self.available.remove(selected)
6464+ else:
6565+ selected = self.solution[-1][1] # select the last node in the solution
6666+ return selected
6767+6868+ def add(self, candidate: tuple[str, str]) -> None:
6969+ """Adds the candidate to the solution."""
7070+ self.solution.append(candidate)
7171+ if self.available:
7272+ self.available.remove(candidate[1])
7373+7474+ def rollback(self, candidate: tuple[str, str]) -> None:
7575+ """Undoes the candidate."""
7676+ self.solution.remove(candidate)
7777+ if self.available:
7878+ self.available.add(candidate[1])
7979+8080+ def choices(self) -> list[str]:
8181+ """Shuffle list of choices."""
8282+ choices = list(self.available)
8383+ random.shuffle(choices)
8484+ return choices
8585+8686+ def backtrack(self) -> bool:
8787+ """Executes a backtrack algorithm to find a solution to the draw (a solution)."""
8888+ if self.is_complete():
8989+ candidate = self.closing_transition()
9090+ if self.is_valid(candidate):
9191+ self.add(candidate)
9292+ logger.info("Solution complete!")
9393+ return True
9494+9595+ logger.debug("Partial solution...")
9696+9797+ current = self.pick()
9898+ logger.debug("Let's pick %s and remove it from available", current)
9999+100100+ for selected in self.choices():
101101+ logger.debug("Selected %s as possible candidate", selected)
102102+103103+ # build transition candidate
104104+ candidate = (current, selected)
105105+106106+ # if the candidate is valid
107107+ if self.is_valid(candidate):
108108+ # build the partial solution
109109+ self.add(candidate)
110110+ # check the partial solution
111111+ logger.debug("- Path: %s", str(self.solution))
112112+ logger.debug("- Available: %s", str(self.available))
113113+ if self.backtrack():
114114+ logger.debug("Candidate %s consolidated!", str(candidate))
115115+ return True
116116+ # if this partial solution can't be finished, rollback
117117+ self.rollback(candidate)
118118+ logger.debug("Candidate %s rollback", str(candidate))
119119+120120+ return False
121121+122122+ def reset(self) -> None:
123123+ """Restores the draw to the initial status."""
124124+ self.solution = []
125125+ self.available = set(self.participants)
126126+127127+ def run(self) -> None:
128128+ """Executes the backtrack algorithm until gets a result."""
129129+ for _ in range(settings.limit):
130130+ if self.backtrack():
131131+ break
132132+ self.reset()
+84
santa/models.py
···11+"""This module contains the models and some tools for using them."""
22+33+from pathlib import Path
44+import yaml
55+66+try:
77+ from yaml import CLoader as Loader
88+except ImportError:
99+ from yaml import Loader
1010+1111+1212+from pydantic import BaseModel
1313+1414+1515+class Player(BaseModel):
1616+ """A player is a participant in the secret santa draw."""
1717+1818+ name: str # the player is identified with the name
1919+ email: str
2020+2121+ def __str__(self):
2222+ return self.name
2323+2424+ def __repr__(self):
2525+ return self.name
2626+2727+2828+class Game(BaseModel):
2929+ """A game game is a set of players."""
3030+3131+ name: str
3232+3333+ notification_template: str = "notification.html"
3434+ notification_from: str = "Amigo Invisible <amigoinvisible@mgabarda.com>"
3535+ notification_subject: str = "Sorteo Amigo Invisible"
3636+3737+ players: dict[str, Player]
3838+ exclusions: list[tuple[str, str]]
3939+4040+ @classmethod
4141+ def create(cls, config_file: Path) -> "Game":
4242+ """Creates the game using the provided config file."""
4343+ assert config_file.is_file(), f"The file {config_file} does not't exists."
4444+4545+ with config_file.open() as file:
4646+ config = yaml.load(file, Loader=Loader)
4747+4848+ secret_santa_config = config["secret-santa"]
4949+5050+ # load players
5151+ players = {
5252+ participant["name"]: Player(
5353+ name=participant["name"], email=participant["email"]
5454+ )
5555+ for participant in secret_santa_config["participants"]
5656+ }
5757+5858+ # load exclusions
5959+ exclusions = []
6060+ for exclusion in secret_santa_config["exclusions"]:
6161+ exclusions.append((exclusion["from"], exclusion["to"]))
6262+ if exclusion.get("reverse", False):
6363+ exclusions.append((exclusion["to"], exclusion["from"]))
6464+6565+ # create game
6666+ game = cls(
6767+ name=secret_santa_config["name"],
6868+ players=players,
6969+ exclusions=exclusions,
7070+ template=secret_santa_config.get("template"),
7171+ )
7272+7373+ # load notification if defined
7474+ notification_config = secret_santa_config.get("notification")
7575+ if notification_config.get("template"):
7676+ game.notification_template = notification_config.get("template")
7777+ if notification_config.get("subject"):
7878+ game.notification_subject = notification_config.get("subject")
7979+8080+ return game
8181+8282+ def participants(self) -> list[str]:
8383+ """Get the list of participants as used y the draw."""
8484+ return [player.name for player in self.players.values()]
+60
santa/notifications.py
···11+"""This module handles the notification process of the result of a draw."""
22+33+import logging
44+55+import httpx
66+from jinja2 import Environment, FileSystemLoader, Template
77+88+from . import settings
99+from .models import Player, Game
1010+from .draws import Draw
1111+1212+logger = logging.getLogger(__name__)
1313+1414+1515+def single_notification(
1616+ game: Game, template: Template, players: tuple[Player, Player]
1717+) -> None:
1818+ """Sends a single notification."""
1919+2020+ # from player 0 to player 1
2121+ _from = players[0]
2222+ _to = players[1]
2323+2424+ content = template.render(
2525+ from_name=_from.name,
2626+ to_name=_to.name,
2727+ )
2828+2929+ # set to email
3030+ destinies = []
3131+ if settings.debug and settings.debug_to_email:
3232+ destinies = [settings.debug_to_email]
3333+ else:
3434+ destinies = [_from.email]
3535+3636+ if destinies:
3737+ response = httpx.post(
3838+ f"{settings.mailgun_api_url}/messages",
3939+ auth=("api", settings.mailgun_api_key.get_secret_value()),
4040+ data={
4141+ "from": game.notification_from,
4242+ "to": destinies,
4343+ "subject": game.notification_subject,
4444+ "html": content,
4545+ },
4646+ )
4747+ response.raise_for_status()
4848+4949+5050+def notify(game: Game, draw: Draw) -> None:
5151+ """Notify the result of the draw in the game."""
5252+5353+ # load template
5454+ environment = Environment(loader=FileSystemLoader("templates/"))
5555+ template = environment.get_template(game.notification_template)
5656+5757+ # iterate over solution
5858+ for _from_name, _to_name in draw.solution:
5959+ players = (game.players[_from_name], game.players[_to_name])
6060+ single_notification(game=game, template=template, players=players)
+6
templates/notification.html
···11+<p>¡Hola {{ from_name }}!</p>
22+33+<p>Tienes que regalar a: <strong>{{ to_name }}</strong>.</p>
44+55+<p>🎁 El Amigo Invisible 🎁</p>
66+