Sekretny Mikołaj w Django

Published Dec. 20, 2025, 9:10 p.m. by damian

Co roku ten sam problem: im więcej osób w gronie, tym trudniej zebrać wszystkich w jednym miejscu na losowanie. Ktoś nie może przyjść, więc losuje za niego kolega. Potem ktoś inny zgubi swoją karteczką z wynikiem. Jeszcze ktoś zapomni komu miał kupić prezent i dzwoni do organizatora w panice dwa dni przed świętami. A organizator? Robi wszystko w Excelu, drukuje karteczki, próbuje skoordynować 15 osób w jednym terminie...

Tym razem postanowiłem to zautomatyzować i przy okazji nauczyć się czegoś nowego w Django.

Tak powstała moja mała aplikacja „Sekretny Mikołaj": dodajesz uczestników, klikasz losuj, a system sam wysyła SMS-y do wszystkich zainteresowanych.


Założenia projektu

Chciałem, żeby aplikacja była:
- prosta w użyciu – formularz: imię + numer telefonu, jedno kliknięcie i gotowe!
- bez „magii" po stronie użytkownika – organizator robi wszystko, uczestnicy tylko dostają SMS
- zintegrowana z bramką SMS – żeby nikt nie musiał logować się na dodatkowe panele

Technicznie: backend to Django, wysyłka odbywa się przez bramkę SMS (wspieramy Twilio i polską bramkę SMSPLANET), wszystko zapakowane w Docker dla łatwego deploymentu.

Struktura projektu

Aplikacja składa się z kilku kluczowych elementów:

secret_santa/
├── models.py          # Modele: Event i Participant
├── views.py           # Logika widoków i obsługa formularza
├── forms.py           # Formularze do wprowadzania uczestników
├── sms_service.py     # Integracja z API SMS
├── urls.py            # Routing
└── templates/         # Szablony HTML
    ├── index.html     # Formularz z listą uczestników
    └── success.html   # Strona potwierdzenia

Modele danych

Stworzyłem dwa proste modele: SecretSantaEvent (wydarzenie) i Participant (uczestnik).

class SecretSantaEvent(models.Model):
    """Model reprezentujący wydarzenie Secret Santa"""
    created_at = models.DateTimeField(default=timezone.now)
    draw_completed = models.BooleanField(default=False)
    draw_date = models.DateTimeField(null=True, blank=True)

    def perform_draw(self):
        """
        Wykonuje losowanie zapewniając, że nikt nie wylosuje siebie.

        Używa algorytmu retry shuffle (derangement):
        - Tasuje odbiorców losowo
        - Sprawdza czy nikt nie wylosował siebie
        - Jeśli tak, powtarza z nowym tasowaniem
        - Powtarza aż do znalezienia poprawnego przypisania
        """
        participants = list(self.participants.all())
        if len(participants) < 3:
            raise ValueError("Wymagane minimum 3 uczestników")

        givers = participants.copy()
        receivers = participants.copy()

        # Retry shuffle until we find valid derangement
        max_attempts = 1000
        for attempt in range(max_attempts):
            random.shuffle(receivers)

            # Check if valid (no one gets themselves)
            if all(givers[i] != receivers[i] for i in range(len(givers))):
                # Valid assignment found!
                break
        else:
            raise ValueError("Nie udało się znaleźć poprawnego przypisania")

        # Przypisanie odbiorców do darczyńców
        for giver, receiver in zip(givers, receivers):
            giver.assigned_to = receiver
            giver.save()

        self.draw_completed = True
        self.draw_date = timezone.now()
        self.save()

Model Participant przechowuje dane uczestnika i informację o tym, kogo wylosował:

class Participant(models.Model):
    """Model reprezentujący uczestnika Secret Santa"""
    event = models.ForeignKey(SecretSantaEvent, on_delete=models.CASCADE,
                             related_name='participants')
    name = models.CharField(max_length=100)
    phone_number = models.CharField(max_length=20,
                                   help_text="Numer z kodem kraju, np. +48123456789")
    assigned_to = models.ForeignKey('self', on_delete=models.SET_NULL,
                                    null=True, blank=True, related_name='secret_santa')
    sms_sent = models.BooleanField(default=False)
    sms_sent_at = models.DateTimeField(null=True, blank=True)

Jak działa logika losowania

Matematycznie losowanie Secret Santa to derangement – permutacja bez punktów stałych. Oznacza to, że: - nikt nie może wylosować siebie - każda osoba ma dokładnie jednego „obdarowywanego" - po wykonaniu losowania przypisania są zapisane (żeby przy błędzie wysyłki można było powtórzyć)

Algorytm Retry Shuffle

Wybrałem prosty i niezawodny algorytm – tasuj aż się uda:

def perform_draw(self):
    participants = list(self.participants.all())
    givers = participants.copy()
    receivers = participants.copy()

    # Retry shuffle until we find valid derangement
    max_attempts = 1000
    for attempt in range(max_attempts):
        random.shuffle(receivers)

        # Check if valid (no one gets themselves)
        if all(givers[i] != receivers[i] for i in range(len(givers))):
            # Valid assignment found!
            break
    else:
        raise ValueError("Could not find valid assignment")

    # Assign receivers to givers
    for giver, receiver in zip(givers, receivers):
        giver.assigned_to = receiver
        giver.save()

Dlaczego to działa?

Każde tasowanie ma ~36.8% szansę że będzie poprawne (żaden nie wylosuje siebie). To oznacza: - Średnio po 2-3 próbach znajdujemy dobre rozwiązanie - Dla 10 osób: prawie natychmiast (<0.001s) - Dla 100 osób: wciąż szybko (~0.01s) - Dla 1000 osób: też szybko (~0.1s)

Algorytm jest prosty, czytelny i gwarantuje poprawność – nigdy nie musisz się martwić o edge cases.

A co z bardziej zaawansowanymi algorytmami?

Istnieje doskonalszy algorytm zwany konstruktywnym derangementem – zamiast tasować na ślepo, algorytmicznie buduje permutację pozycja po pozycji, upewniając się że nigdy nie wstawimy osoby na jej własne miejsce. Bardziej elegancko, ale też bardziej złożony kod.

Na etapie nauki (samouczek, pierwsza praca)? Retry shuffle w zupełności wystarczy. Jest łatwy do zrozumienia, szybki dla praktycznych rozmiarów grup, i skoncentrowany na rozwiązaniu problemu – nie optymalizacji premature'ów.

Bardziej zaawansowane algorytmy przyjdą z czasem, z setkami tysięcy rekordów na sekundę. Na razie skupiam się na tym, żeby aplikacja działała i była zrozumiała.

Integracja z bramką SMS

Wybrałem architekturę pluginowalną – aplikacja wspiera różne providery SMS poprzez ustawienie w konfiguracji:

class SMSService:
    """Abstrakcyjna usługa SMS wspierająca różnych providerów"""

    @staticmethod
    def send_sms(to_number, message):
        provider = getattr(settings, 'SMS_PROVIDER', 'console')

        if provider == 'twilio':
            return SMSService._send_via_twilio(to_number, message)
        elif provider == 'smsplanet':
            return SMSService._send_via_smsplanet(to_number, message)
        elif provider == 'console':
            return SMSService._send_via_console(to_number, message)

SMSPLANET – polska bramka SMS

Wybrałem SMSPLANET jako główny provider, bo: - jest polski, więc dokumentacja i wsparcie „po naszemu" - oferuje masową wysyłkę SMS przez API - obsługuje polskie znaki (ą, ć, ę, ł, ń, ó, ś, ź, ż) - ma przystępne ceny dla polskiego rynku

Implementacja wysyłki przez SMSPLANET:

@staticmethod
def _send_via_smsplanet(to_number, message):
    """Wysyłka SMS przez SMSPLANET API"""
    api_token = getattr(settings, 'SMSPLANET_API_TOKEN', None)
    sender_field = getattr(settings, 'SMSPLANET_SENDER_FIELD', 'INFO')

    if not api_token:
        logger.error("Token API SMSPLANET nie jest skonfigurowany")
        return False

    # Normalizacja numeru (usunięcie +)
    phone = to_number.replace('+', '').replace(' ', '')

    # Przygotowanie requestu
    url = 'https://api2.smsplanet.pl/sms'
    headers = {
        'Authorization': f'Bearer {api_token}',
        'Content-Type': 'application/x-www-form-urlencoded'
    }

    data = {
        'from': sender_field,
        'to': phone,
        'msg': message
    }

    # Wysłanie
    response = requests.post(url, headers=headers, data=data, timeout=10)

    if response.status_code == 200:
        response_data = response.json()
        if 'messageId' in response_data:
            logger.info(f"SMS wysłany przez SMSPLANET do {to_number}")
            return True

    return False

Wyzwanie: różne providery, różne możliwości

Ciekawym problemem technicznym było to, że SMSPLANET nie obsługuje emoji w wiadomościach. Rozwiązałem to przez tworzenie różnych wersji wiadomości w zależności od providera:

def send_secret_santa_sms(participant):
    """Wysyła SMS z przypisaniem Secret Santa"""
    provider = getattr(settings, 'SMS_PROVIDER', 'console')

    # SMSPLANET nie obsługuje emoji - wersja tekstowa
    if provider == 'smsplanet':
        message = (
            f"Cześć {participant.name}!\n\n"
            f"W tegorocznym Secret Santa wylosowałeś/aś: {participant.assigned_to.name}\n\n"
            f"To jest tajna informacja - nikomu nie mów!\n\n"
            f"Wesołych Świąt!"
        )
    else:
        # Twilio i Console obsługują emoji
        message = (
            f"Cześć {participant.name}! 🎅\n\n"
            f"W tegorocznym Secret Santa wylosowałeś/aś: {participant.assigned_to.name}\n\n"
            f"To jest tajna informacja - nikomu nie mów! 🤫\n\n"
            f"Wesołych Świąt! 🎄"
        )

    return SMSService.send_sms(participant.phone_number, message)

Obsługa formularzy i walidacja

Django FormSet pozwala na wygodne dodawanie wielu uczestników na raz:

class ParticipantForm(forms.Form):
    """Formularz dla pojedynczego uczestnika"""
    name = forms.CharField(max_length=100)
    phone_number = forms.CharField(max_length=20)

    def clean_phone_number(self):
        phone = self.cleaned_data['phone_number']
        # Walidacja - musi zaczynać się od + i zawierać tylko cyfry
        if not phone.startswith('+'):
            raise forms.ValidationError('Numer telefonu musi zaczynać się od + i kodu kraju')
        if not phone[1:].replace(' ', '').isdigit():
            raise forms.ValidationError('Numer telefonu może zawierać tylko cyfry po znaku +')
        return phone

# FormSet pozwalający na co najmniej 3 uczestników
ParticipantFormSet = formset_factory(
    ParticipantForm,
    extra=3,
    min_num=3,
    validate_min=True
)

Widok główny – gdzie wszystko się dzieje

Widok łączy wszystkie elementy: formularze, losowanie, wysyłkę SMS:

def secret_santa_view(request):
    if request.method == 'POST':
        formset = ParticipantFormSet(request.POST)

        if formset.is_valid():
            try:
                with transaction.atomic():
                    # Utwórz wydarzenie
                    event = SecretSantaEvent.objects.create()

                    # Dodaj uczestników
                    for form in formset:
                        if form.cleaned_data:
                            Participant.objects.create(
                                event=event,
                                name=form.cleaned_data['name'],
                                phone_number=form.cleaned_data['phone_number']
                            )

                    # Wykonaj losowanie
                    event.perform_draw()

                    # Wyślij SMS-y
                    success_count = 0
                    for participant in event.participants.all():
                        if send_secret_santa_sms(participant):
                            success_count += 1

                    messages.success(request,
                        f'🎅 Losowanie zakończone! Wysłano {success_count} SMS-ów.')
                    return redirect('secret_santa:success')

            except Exception as e:
                logger.error(f"Błąd podczas losowania: {e}")
                messages.error(request, 'Wystąpił błąd. Spróbuj ponownie.')
    else:
        formset = ParticipantFormSet()

    return render(request, 'secret_santa/index.html', {'formset': formset})

Inne zastosowania – automatyczna wysyłka SMS

Ten projekt to nie tylko o losowaniu – kluczem jest automatyczne powiadamianie przez SMS. System wysyłki SMS można wykorzystać w wielu scenariuszach:

Zastosowania z losowaniem:

  • Losowanie par do mentoringu – w firmie można użyć tego do parowania juniorów z seniorami, każdy dostaje SMS z informacją kto jest jego mentorem
  • „Buddy system" – przy onboardingu nowych pracowników, losowanie „opiekunów" i automatyczne powiadomienia
  • Coffee chats w zespole rozproszonym – losowanie par do nieformalnych rozmów, z wysyłką informacji kiedy i z kim masz spotkanie
  • Losowanie zadań w wolontariacie – automatyczne przypisywanie „komu pomagasz" z powiadomieniem SMS

Zastosowania samej wysyłki SMS (bez losowania):

  • Przypomnienia o terminach – automatyczne SMS-y o deadline'ach projektów, spotkaniach, przeglądach
  • Potwierdzenia rejestracji – wysyłka kodów weryfikacyjnych, potwierdzeń zapisów na wydarzenia
  • Powiadomienia o statusie – informacje o zmianie statusu zamówienia, zgłoszenia, zadania
  • Alerty systemowe – powiadomienia o błędach, problemach z serwerem, przekroczeniu limitów
  • Kampanie SMS – masowa wysyłka informacji marketingowych, ankiet, zaproszeń
  • Dwuetapowa autoryzacja (2FA) – wysyłka kodów jednorazowych do logowania

Wystarczy zmienić treść wiadomości i dostosować logikę wyzwalania wysyłki. Cała infrastruktura SMS (wybór providera, obsługa błędów, tracking doręczeń) jest już gotowa i uniwersalna.

Wnioski i nauka

Co poszło dobrze:
- Django ORM jest intuicyjne – modele i relacje były proste do zdefiniowania
- FormSets to potężne narzędzie do obsługi list formularzy
- Integracja z zewnętrznym API (SMS) – głównie kwestia dobrze przeczytanej dokumentacji
- Algorytm derangement (retry shuffle) – prosty i skuteczny

Wyzwania techniczne:
- Debugowanie wysyłki SMS – w development mode trudno testować płatne API, stąd powstał backend "console"
- Obsługa polskich znaków i emoji – różne providery mają różne ograniczenia (SMSPLANET nie obsługuje emoji)
- Walidacja numerów telefonów – każdy kraj ma inny format, trzeba było to uwzględnić
- Transakcje atomowe w Django – zapewnienie spójności danych przy błędach wysyłki

Co można poprawić:
- Dodać autoryzację, żeby każdy organizator miał własne wydarzenia
- Lepszy UI/UX – może framework frontendowy?
- Testy jednostkowe i integracyjne
- System raportów – statystyki doręczeń, koszty SMS, historia wydarzeń
- Monitoring wysyłek – dashboard z real-time statusem doręczeń

Podsumowanie

Projekt „Sekretny Mikołaj" to świetny sposób na naukę Django w praktyce. Zamiast przerabiać kolejny tutorial, zbudowałem coś co faktycznie rozwiązuje realny problem i można to pokazać znajomym.

Cały kod dostępny jest w moim repozytorium GitLab. Jeśli masz pomysł jak rozwinąć taki projekt albo widzisz dla siebie zastosowanie – chętnie o tym poczytam w komentarzu.


Stack technologiczny: - Python 3.12 - Django 5.0 - SQLite - SMS API: Twilio / SMSPLANET

Linki:
- Repozytorium GitLab: https://gitlab.com/damiankrzystolik/secret-santa
- Dokumentacja SMSPLANET: https://api2.smsplanet.pl/
- Dokumentacja Twilio: https://www.twilio.com/docs/sms

Podobne posty

0 komentarzy

Dodaj nowy komentarz

Please provide your name.
Please provide a valid email.
Please enter your comment.