Dataczwartek, 13 sierpnia 2026 Czas18:48:25
← Aplikacje webowe

Lekcja 26. Projekt 7: System głosowania i ankiet — wyniki na żywo

Trudny / egzaminacyjny

Po co się tego uczymy?

Czwarta i ostatnia z funkcjonalności nazwanych wprost w podstawie programowej (INF.04.7.3) — system głosowania/ankiet. Ten projekt uczy dwóch nowych umiejętności: AGREGACJI danych (zliczanie głosów pogrupowanych po opcji) oraz PREZENTACJI wyników jako pasków procentowych — a także krótkiego wprowadzenia do odświeżania danych "na żywo" (odpytywanie/polling), zanim w Dodatku poznasz Blazor Server, który ten sam problem rozwiązuje inaczej.

Teoria

Wymagania projektu:

  • Tworzenie ankiety (WYMAGA zalogowania) — pytanie + od 2 do N opcji odpowiedzi;
  • Głosowanie — KAŻDY zalogowany użytkownik może oddać JEDEN głos w danej ankiecie (blokada podwójnego głosowania);
  • Wyniki jako wykres / paski procentowe — po oddaniu głosu (albo jeśli użytkownik już głosował) widoczne są wyniki: liczba głosów na każdą opcję ORAZ procent, zwizualizowany jako pasek o szerokości proporcjonalnej do procentu;
  • Lista aktywnych ankiet oraz historia zakończonych.

Agregacja danych w EF Core — GroupBy i zliczanie. Do tej pory pobierałeś dane wprost (Sklep, lekcja 22) albo liczyłeś proste sumy przez .Count() w DTO (Portal społecznościowy, lekcja 24). Tutaj potrzebujesz zliczyć głosy pogrupowane WEDŁUG opcji odpowiedzi — do tego służy GroupBy:

var wyniki = await context.Glosy
    .Where(g => g.AnkietaId == ankietaId)
    .GroupBy(g => g.OpcjaId)
    .Select(grupa => new WynikOpcjiDto
    {
        OpcjaId = grupa.Key,
        LiczbaGlosow = grupa.Count()
    })
    .ToListAsync();

GroupBy(g => g.OpcjaId) dzieli WSZYSTKIE głosy danej ankiety na "koszyki" — jeden koszyk na każdą unikalną wartość OpcjaId. grupa.Key to identyfikator koszyka (czyli ID opcji), a grupa.Count() liczy, ile głosów trafiło do TEGO koszyka. EF Core tłumaczy to na SQL-owe GROUP BY z COUNT(*) — CAŁA agregacja dzieje się w bazie danych, nie w pamięci aplikacji.

Blokada podwójnego głosowania — unikalne ograniczenie na parze kolumn. Żeby użytkownik NIE MÓGŁ zagłosować dwa razy w tej samej ankiecie, tabela Glosy ma unikalny indeks na PARZE kolumn (AnkietaId, UzytkownikId) — konfigurowany w OnModelCreating:

modelBuilder.Entity<Glos>()
    .HasIndex(g => new { g.AnkietaId, g.UzytkownikId })
    .IsUnique();

Dzięki temu baza danych SAMA odrzuci próbę wstawienia drugiego głosu tego samego użytkownika w tej samej ankiecie (nawet gdyby backend miał błąd w logice) — to DODATKOWA warstwa ochrony NIEZALEŻNA od sprawdzania w kodzie C#.

Paski procentowe w Angularze — wiązanie stylu przez [style.width]. Zamiast wykresu z zewnętrznej biblioteki, wynik można pokazać jako prosty pasek <div>, którego szerokość wiążemy DYNAMICZNIE z obliczonym procentem: [style.width.%]="opcja.procent" — Angular sam przelicza to na style="width: 42%" w renderowanym HTML-u.

Schemat

POST /api/ankiety/5/glosuj { opcjaId: 3 }
        │
        ▼
Backend: sprawdź unikalny indeks (AnkietaId, UzytkownikId)
  ├─ już głosował → 409 Conflict
  └─ nowy głos    → zapis do tabeli Glosy → 200 OK
        │
        ▼
GET /api/ankiety/5/wyniki
        │
        ▼
context.Glosy.Where(AnkietaId=5).GroupBy(OpcjaId).Select(Count())
        │
        ▼
[ {opcja:"Tak", liczba:12, procent:60}, {opcja:"Nie", liczba:8, procent:40} ]
        │
        ▼
Angular: <div class="pasek" [style.width.%]="opcja.procent">{{opcja.procent}}%</div>

Przykład z życia

Ankiety na żywo podczas prezentacji (np. Slido, Mentimeter) czy głosowania w konkursach internetowych działają dokładnie na tej zasadzie: jeden głos na użytkownika, wyniki agregowane w czasie rzeczywistym i pokazywane jako paski lub wykres kołowy — pokazuje to, jak duża część "efektu wow" takich narzędzi to w istocie prosty GROUP BY w bazie danych i CSS.

Controllers/AnkietyController.cs

// Controllers/AnkietyController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;

namespace AnkietyApp.Server.Controllers;

[ApiController]
[Route("api/[controller]")]
public class AnkietyController : ControllerBase
{
    private readonly AnkietyContext _context;
    public AnkietyController(AnkietyContext context) => _context = context;

    [Authorize]
    [HttpPost("{id}/glosuj")]
    public async Task<IActionResult> Glosuj(int id, [FromBody] GlosDto dane)
    {
        var uzytkownikId = User.FindFirstValue(ClaimTypes.NameIdentifier)!;

        bool jużGłosował = await _context.Glosy
            .AnyAsync(g => g.AnkietaId == id && g.UzytkownikId == uzytkownikId);

        if (jużGłosował)
            return Conflict("Już oddałeś głos w tej ankiecie.");

        _context.Glosy.Add(new Glos
        {
            AnkietaId = id,
            OpcjaId = dane.OpcjaId,
            UzytkownikId = uzytkownikId
        });
        await _context.SaveChangesAsync();

        return Ok();
    }

    [HttpGet("{id}/wyniki")]
    public async Task<ActionResult<List<WynikOpcjiDto>>> Wyniki(int id)
    {
        var opcje = await _context.OpcjeOdpowiedzi
            .Where(o => o.AnkietaId == id)
            .ToListAsync();

        var glosyPogrupowane = await _context.Glosy
            .Where(g => g.AnkietaId == id)
            .GroupBy(g => g.OpcjaId)
            .Select(grupa => new { OpcjaId = grupa.Key, Liczba = grupa.Count() })
            .ToListAsync();

        int razem = glosyPogrupowane.Sum(g => g.Liczba);

        var wyniki = opcje.Select(o =>
        {
            int liczba = glosyPogrupowane.FirstOrDefault(g => g.OpcjaId == o.Id)?.Liczba ?? ;
            return new WynikOpcjiDto
            {
                Tresc = o.Tresc,
                LiczbaGlosow = liczba,
                Procent = razem ==  ?  : Math.Round(liczba * 100.0 / razem, 1)
            };
        }).ToList();

        return Ok(wyniki);
    }
}

wyniki-ankiety.component (szablon + fragment)

<!-- wyniki-ankiety.component.html - paski procentowe -->
<div class="wyniki">
  <div class="opcja" *ngFor="let opcja of wyniki">
    <div class="opcja__naglowek">
      <span>{{ opcja.tresc }}</span>
      <span>{{ opcja.liczbaGlosow }} głosów ({{ opcja.procent }}%)</span>
    </div>
    <div class="pasek-tlo">
      <div class="pasek" [style.width.%]="opcja.procent"></div>
    </div>
  </div>
</div>

<!-- wyniki-ankiety.component.ts (fragment) -->
<!--
export class WynikiAnkietyComponent implements OnInit, OnDestroy {
  wyniki: WynikOpcji[] = [];
  private odswiezanie?: Subscription;

  ngOnInit(): void {
    this.pobierzWyniki();
    // odpytywanie co 3 sekundy - proste "odświeżanie na żywo"
    this.odswiezanie = interval(3000).subscribe(() => this.pobierzWyniki());
  }

  ngOnDestroy(): void {
    this.odswiezanie?.unsubscribe();
  }

  pobierzWyniki(): void {
    this.ankietyService.pobierzWyniki(this.ankietaId)
      .subscribe(dane => this.wyniki = dane);
  }
}
-->

Komentarz i wyjaśnienie kodu

AnyAsync(g => g.AnkietaId == id && g.UzytkownikId == uzytkownikId) to sprawdzenie WYSTĄPIENIA głosu w kodzie C#, WSPIERAJĄCE (nie zastępujące) unikalny indeks bazodanowy — dwie niezależne warstwy ochrony przed podwójnym głosowaniem.

W metodzie Wyniki najpierw pobierane są WSZYSTKIE opcje ankiety (nawet te z zerem głosów), a DOPIERO POTEM głosy są grupowane — dzięki temu opcja, na którą jeszcze NIKT nie zagłosował, też pojawi się w wynikach z LiczbaGlosow = 0, zamiast zniknąć z listy.

[style.width.%]="opcja.procent" to wiązanie właściwości STYLU (nie atrybutu) — Angular ustawia bezpośrednio element.style.width w przeglądarce, z automatycznie dodaną jednostką % dzięki przyrostkowi .% w nazwie wiązania.

interval(3000).subscribe(...) w komentarzu TypeScript pokazuje wzorzec "odpytywania" (ang. polling) — najprostszy sposób na "wyniki na żywo": co 3 sekundy komponent PONOWNIE pyta backend o aktualne dane. To rozwiązanie DZIAŁA, ale nie jest tak efektywne jak prawdziwe połączenie czasu rzeczywistego (np. SignalR) — o czym więcej dowiesz się w Dodatku przy okazji Blazor Server.

Ćwiczenie samodzielne

Zbuduj kontroler z metodami Glosuj (z blokadą podwójnego głosu) i Wyniki (z GroupBy) jak w przykładzie. Sprawdź w bazie danych, że unikalny indeks na (AnkietaId, UzytkownikId) faktycznie uniemożliwia wstawienie drugiego głosu tego samego użytkownika.

Zadania do pracy własnej

  1. Dodaj do formularza tworzenia ankiety walidację: pytanie nie może być puste, muszą być co najmniej 2 opcje odpowiedzi (walidacja zarówno w Angularze przez Reactive Forms, jak i w C# przez adnotacje modelu).

  2. Dodaj do modelu Ankieta pole DataZakonczenia — po tej dacie ankieta powinna być oznaczona jako zakończona (osobna zakładka "Zakończone ankiety"), a próba oddania głosu w zakończonej ankiecie ma zwracać błąd.

  3. Zamień odpytywanie co 3 sekundy (interval) na prawdziwe powiadomienia w czasie rzeczywistym przy użyciu SignalR (biblioteka ASP.NET Core do komunikacji dwukierunkowej) — backend wysyła aktualizację wyników do WSZYSTKICH podłączonych klientów NATYCHMIAST po każdym nowym głosie, zamiast czekać, aż klient sam zapyta.

Typowe błędy

Poleganie WYŁĄCZNIE na sprawdzeniu w C# (AnyAsync), bez unikalnego indeksu w bazie danych — przy dwóch niemal jednoczesnych żądaniach (np. użytkownik szybko kliknie dwa razy) obie kopie kodu C# mogą sprawdzić "jeszcze nie głosował" ZANIM którakolwiek zdąży zapisać głos — bez unikalnego indeksu oba głosy zostałyby zapisane. Baza danych jako ostateczna linia obrony jest tu NIEZBĘDNA, nie opcjonalna.

Grupowanie TYLKO po głosach, bez wcześniejszego pobrania WSZYSTKICH opcji — opcja bez żadnego głosu w ogóle nie pojawiłaby się w wyniku GroupBy, co zniekształca wyświetlane wyniki (brakująca opcja zamiast "0 głosów, 0%").

Dzielenie przez zero przy liczeniu procentu, gdy ankieta nie ma jeszcze ŻADNYCH głosów (razem == 0) — trzeba jawnie obsłużyć ten przypadek, zwracając 0%, zamiast pozwolić na wyjątek albo wynik NaN.

Nawiązanie do egzaminu zawodowego

To ostatnia z czterech funkcjonalności nazwanych wprost w INF.04.7.3 (obok sklepu, portalu i serwisu ogłoszeniowego) — tym razem oparta na agregacji danych przez GroupBy, nowej technice w tym kursie. Po tym projekcie pozostał już tylko jeden, ósmy i ostatni projekt (lekcja 27) — system rezerwacji połączony z zadaniem kontrolnym podsumowującym CAŁY dział.