Lekcja 26. Projekt 7: System głosowania i ankiet — wyniki na żywo
Trudny / egzaminacyjnyPo 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
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).
Dodaj do modelu
AnkietapoleDataZakonczenia— 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.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ł.