Java 3 - 2026

Final Exam

Part 1

Week 1 - Setup Pet Clinic App. Deployment using Docker.
Week 2 - Domains, AWS, ER Diagrams and Databases, Understand the Pet Clinic Setup.

Week 3 - MVC, Repository interfaces, Thymeleaf, Internationalization, Controller Unit Tests,

Week 4 - Use Case Narrative, Sequence Diagram, State Chart Diagram, Create new objects.
Lesson 5 - Users, roles, and permissions setup from Java 2. Unit Tests. Show object details.

Lesson 6 - User Registration, flash messages, Session Cookies

Lesson 7 - Login and logout. Midterm Review. Manage the user profile. More form controls.

Lesson 8 - Midterm exam. Prevent duplicate insert records.

Lesson 9 - Edit and delete user profile. Password reset. GitHub Actions.

Lesson 10 - Recipe API. User permissions. Custom not found page.
Lessons 11-12 - Update and delete existing Location objects.
Lessons 13-14 - Azure
Email.  Web Sockets.

Final Exam - CRUD Leagues (fill in the blanks).

Did not get to - Search, sort, filter records. Date and currency formatting. Failed login attempts.  SMS messaging. Shopping Cart. Payment processing. Team Registration. Event Creation.

Course Plan

  • http://localhost:8080/schools/xxx Doesn't show 404 error.

  • SL4FJ Logging (See EmailService)

  • Delete reset_token on login

  • Add a failed to connect toast if cannot connect to db

  • Sort and filter the list of schools

  • Add school logo, color

  • If the user enters an incorrect email or password N times, can their account be disabled?

  • Update messages.properties files to include needed translations for the registration form headings, labels, and flash messages
  • Stored procedures
  • Get the location's lat/lon from LocationIQ

  • Get weather forecast at the location's lat/lon from the OpenWeatherAPI

  • School Admin upload student data

Next

Next

  • XSS Attacks, other OWASP

  • CSRF? Security
  • First name field

  • <img src="http://localhost:9999/city.png" onclick="location='http://packt.com'">

  • <button class="btn btn-danger" onclick="location='http://packt.com'">Click</button>

  • An internal server error occurred.

    could not execute statement [Data truncation: Data too long for column 'first_name' at row 1]

  • SQL injection

  • Juice box app

Next

  • X

More Create Table Statements

DROP TABLE IF EXISTS messages;
CREATE TABLE messages (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id_from INT NOT NULL,
    user_id_to INT,
    league_id INT,
    event_id INT,
    parent_message_id INT, -- For threaded replies
    message VARCHAR(255) NOT NULL,
    is_flagged TINYINT DEFAULT 0,
    status_id ENUM('draft', 'active', 'hidden') DEFAULT 'active',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    CONSTRAINT fk_messages_from FOREIGN KEY (user_id_from) REFERENCES users(id) ON DELETE CASCADE,
    CONSTRAINT fk_messages_to FOREIGN KEY (user_id_to) REFERENCES users(id) ON DELETE SET NULL,
    CONSTRAINT fk_messages_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
    CONSTRAINT fk_messages_event FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
    CONSTRAINT fk_messages_parent FOREIGN KEY (parent_message_id) REFERENCES messages(id) ON DELETE CASCADE,

    -- Performance: Quickly load chat history for a league or event
    INDEX idx_messages_context (league_id, event_id)
);

DROP TABLE IF EXISTS message_reactions;
CREATE TABLE message_reactions (
    id INT AUTO_INCREMENT PRIMARY KEY,
    message_id INT NOT NULL,
    user_id INT NOT NULL,
    reaction ENUM('like', 'dislike', 'love', 'hug', 'sad', 'angry'),
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    
    CONSTRAINT fk_reactions_message FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
    CONSTRAINT fk_reactions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,

    -- Integrity: One reaction per user per message
    UNIQUE INDEX idx_reactions_unique (message_id, user_id)
);
  • If I make a change to the profile.html file and make a new POST request without refreshing the page, I get a "Whitelabel Error Page" saying:

    ```

    This application has no explicit mapping for /error, so you are seeing this as a fallback.

    Sun Mar 01 23:11:58 CST 2026

    There was an unexpected error (type=Internal Server Error, status=500).

    User not found

    java.lang.RuntimeException: User not found

    at org.springframework.samples.petclinic.user.ProfileController.lambda$processProfileUpdate$1(ProfileController.java:82)

    ```

    Is there a way to customize the Whitelabel Error Page

Bootstrap Styling

  • It is possible to replace Thymeleaf with a frontend JavaScript framework like React, Angular, or Vue.

  • This involves shifting your application from a Server-Side Rendered (SSR) architecture to a Client-Side Rendered (CSR) or Single Page Application (SPA) architecture.

  • Here is how that transition works:

    • Thymeleaf: The Spring Boot server generates the full HTML page. It merges the data (e.g., a list of Vets) with the template (vetList.html) and sends the finished HTML to the browser.

    • React/Angular/Vue: The Spring Boot server becomes a REST API. It sends only the raw data (usually in JSON format). The JavaScript framework running in the browser receives that JSON and builds the HTML dynamically.

Frontend JavaScript Frameworks

  • A benefit of the layered architecture (Controller > Service > Repository) is that you do not need to change your Database or Repository layers—you only need to modify the Controller layer.

  • If you look at VetController.java, the application already has an endpoint ready for a JavaScript framework to use:

Frontend JavaScript Frameworks

// This method is for Thymeleaf (Returns a View)
@GetMapping("/vets.html")
public String showVetList(...) { ... }

// This method is for External Clients/JS Frameworks (Returns JSON/XML)
@GetMapping({ "/vets" })
public @ResponseBody Vets showResourcesVetList() {
    Vets vets = new Vets();
    vets.getVetList().addAll(this.vetRepository.findAll());
    return vets;
}
  • The @ResponseBody annotation tells Spring: "Do not look for a Thymeleaf template. Just take this Java object, convert it to JSON, and send it back."

  • A React or Angular app would make a fetch('/vets') call to this URL, receive the list of doctors, and render the table itself.

  • To fully replace Thymeleaf, you would:

  • Update Controllers: Change your @Controller classes to @RestController (which automatically applies @ResponseBody to every method).

  • Return Data, Not Strings: Instead of returning strings like "owners/createOrUpdateOwnerForm", your methods would return Owner objects or ResponseEntity objects.

  • Delete Templates: You would eventually delete the src/main/resources/templates folder since the Java app no longer generates HTML.

  • Frontend Build: You would build your React/Angular app separately. You can then either run it on a separate server (like Node.js) that talks to your Spring Boot API, or package the built JavaScript files into the Spring Boot static folder to serve them together.

Frontend JavaScript Frameworks

Complete this column for up to 30% of your final grade.

Start with a working copy of the Athleagues demo project or clone a new copy before beginning.

  • Create the following database table to store league data. 
  • YOUR TASK: To prevent duplicate records from being seeded, you need to define what constitutes a "duplicate" league by adding a UNIQUE constraint to the table. For intramural leagues, a combination of the school_id and the league name is a reliable natural key (for example, Kirkwood would have only one league called "Fall 2026 5v5 Basketball").
  • Also, add this to your "resources/db/mysql/schema.sql" file.

Create Leagues

CREATE TABLE IF NOT EXISTS leagues (
   id INT AUTO_INCREMENT PRIMARY KEY,
   school_id INT NOT NULL,
   location_id INT, 
   user_id INT, 
   name VARCHAR(255) NOT NULL,
   description TEXT,
   registration_start DATETIME,
   registration_end DATETIME,
   league_start DATETIME,
   league_end DATETIME,
   is_public TINYINT DEFAULT 1,
   type ENUM('MALE', 'FEMALE', 'COED') NOT NULL,
   capacity INT,
   capacity_type ENUM('TEAM', 'INDIVIDUAL') NOT NULL,
   fee DECIMAL(6,2),
   status_id ENUM('DRAFT', 'ACTIVE', 'INACTIVE', 'POSTPONED', 'CANCELLED', 'PAST') DEFAULT 'DRAFT',
   copied_from_id INT NULL,
   created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
   updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
   deleted_at DATETIME,

   CONSTRAINT fk_leagues_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
   CONSTRAINT fk_leagues_location FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE SET NULL,
   CONSTRAINT fk_leagues_manager FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
   CONSTRAINT fk_leagues_copied_from FOREIGN KEY (copied_from_id) REFERENCES leagues(id) ON DELETE SET NULL,

   INDEX idx_leagues_school_status (school_id, status_id),
   
   -- TODO: Add a line to prevent duplicate seed data
);
  • SOLUTION

Create Leagues

CREATE TABLE IF NOT EXISTS leagues (
   id INT AUTO_INCREMENT PRIMARY KEY,
   school_id INT NOT NULL,
   location_id INT, 
   user_id INT, 
   name VARCHAR(255) NOT NULL,
   description TEXT,
   registration_start DATETIME,
   registration_end DATETIME,
   league_start DATETIME,
   league_end DATETIME,
   is_public TINYINT DEFAULT 1,
   type ENUM('MALE', 'FEMALE', 'COED') NOT NULL,
   capacity INT,
   capacity_type ENUM('TEAM', 'INDIVIDUAL') NOT NULL,
   fee DECIMAL(6,2),
   status_id ENUM('DRAFT', 'ACTIVE', 'INACTIVE', 'POSTPONED', 'CANCELLED', 'PAST') DEFAULT 'DRAFT',
   copied_from_id INT NULL,
   created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
   updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
   deleted_at DATETIME,

   CONSTRAINT fk_leagues_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
   CONSTRAINT fk_leagues_location FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE SET NULL,
   CONSTRAINT fk_leagues_manager FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
   CONSTRAINT fk_leagues_copied_from FOREIGN KEY (copied_from_id) REFERENCES leagues(id) ON DELETE SET NULL,

   INDEX idx_leagues_school_status (school_id, status_id),
   
   -- Add this line to prevent duplicate seed data
   UNIQUE INDEX idx_leagues_school_name (school_id, name)
);
  • Create the following database tables to teams and team_user data. 
  • To prevent duplicate records from being seeded, you need to define what constitutes a "duplicate" team by adding a UNIQUE constraint to the table. For teams, a combination of the league_id and the team name is a reliable natural key.
  • Add these to your "resources/db/mysql/schema.sql" file.

Create Teams

CREATE TABLE IF NOT EXISTS teams (
    id INT AUTO_INCREMENT PRIMARY KEY,
    league_id INT NOT NULL,
    captain_user_id INT NOT NULL,
    name VARCHAR(255) NOT NULL,
    logo_url VARCHAR(255),
    status_id ENUM('ACTIVE', 'INACTIVE', 'SUSPENDED') DEFAULT 'ACTIVE',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at DATETIME,

    CONSTRAINT fk_teams_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
    CONSTRAINT fk_teams_captain FOREIGN KEY (captain_user_id) REFERENCES users(id) ON DELETE CASCADE,

    -- Performance: List all teams in a league
    INDEX idx_teams_league (league_id),
    
    -- Add this line to prevent duplicate seed data
   UNIQUE INDEX idx_leagues_team_name (league_id, name)
);

CREATE TABLE IF NOT EXISTS team_users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    team_id INT NOT NULL,
    user_id INT NOT NULL,
    role ENUM('MEMBER', 'CAPTAIN') DEFAULT 'MEMBER',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at DATETIME,

    CONSTRAINT fk_team_users_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
    CONSTRAINT fk_team_users_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,

    -- Integrity: A user cannot join the same team twice
    UNIQUE INDEX idx_team_users_unique (team_id, user_id)
);
  • Create the following database tables to store event and match data. 
  • To prevent duplicate records from being seeded, you need to define what constitutes a "duplicate" event by adding a UNIQUE constraint to the table. For teams, a combination of the league_id and the event name is a reliable natural key.
  • Also, add these to your "resources/db/mysql/schema.sql" file.

Create Events and Matches

CREATE TABLE IF NOT EXISTS events (
    id INT AUTO_INCREMENT PRIMARY KEY,
    league_id INT NOT NULL,
    location_id INT,
    user_id INT, -- Event organizer/referee
    name VARCHAR(255) NOT NULL,
    description TEXT,
    event_start DATETIME,
    event_end DATETIME,
    status_id ENUM('DRAFT', 'ACTIVE', 'ONGOING', 'POSTPONED', 'CANCELLED', 'FINAL') DEFAULT 'DRAFT',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at DATETIME,

    CONSTRAINT fk_events_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
    CONSTRAINT fk_events_location FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE SET NULL,
    CONSTRAINT fk_events_contact FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,

    -- Integrity & Performance: Check for double-bookings at a location
    INDEX idx_events_location_time (location_id, event_start),
    
    -- Add this line to prevent duplicate seed data
   UNIQUE INDEX idx_leagues_event_name (league_id, name)
);

CREATE TABLE IF NOT EXISTS matches (
    id INT AUTO_INCREMENT PRIMARY KEY,
    event_id INT NOT NULL,
    home_team_id INT,
    away_team_id INT,
    winner_team_id INT,
    home_score INT DEFAULT 0,
    away_score INT DEFAULT 0,
    status ENUM('SCHEDULED', 'IN_PROGRESS', 'FINAL', 'FORFEIT') DEFAULT 'SCHEDULED',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at DATETIME,

    CONSTRAINT fk_matches_event FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
    CONSTRAINT fk_matches_home FOREIGN KEY (home_team_id) REFERENCES teams(id) ON DELETE SET NULL,
    CONSTRAINT fk_matches_away FOREIGN KEY (away_team_id) REFERENCES teams(id) ON DELETE SET NULL,
    CONSTRAINT fk_matches_winner FOREIGN KEY (winner_team_id) REFERENCES teams(id) ON DELETE SET NULL,

    -- Performance: Essential for calculating standings (W/L records)
    INDEX idx_matches_home (home_team_id),
    INDEX idx_matches_away (away_team_id)
);

data.sql

-- Create a League
INSERT IGNORE INTO leagues
(school_id, location_id, user_id, name, description, registration_start, registration_end, league_start, league_end, is_public, type, capacity, capacity_type, fee, status_id)
VALUES
  (1, 3, (SELECT id FROM users WHERE email = 'brett.baumgart@kirkwood.edu'), 'Fall 2026 5v5 Basketball', 'Competitive 5-on-5 men''s basketball league.', '2026-08-01 08:00:00', '2026-08-31 23:59:59', '2026-09-07 18:00:00', '2026-11-15 22:00:00', 1, 'male', 12, 'team', 50.00, 'active');

  • Here are some INSERT IGNORE INTO statements to seed your new tables in your resources/db/mysql/data.sql file.

  • To ensure the foreign keys map correctly regardless of the auto-increment state in your database, these statements use subqueries based on the names and emails provided in previous insert blocks.

  • You must have previous roles, users, and locations set up.

data.sql

-- 1. Insert a second student to act as an opposing team captain
INSERT IGNORE INTO users (first_name, last_name, email, password_hash) VALUES
  ('Sam', 'Student', 'sam.student@student.kirkwood.edu', 'hashed_password_for_sam');

INSERT IGNORE INTO user_roles (user_id, role_id) VALUES
  ((SELECT id FROM users WHERE email = 'sam.student@student.kirkwood.edu'), (SELECT id FROM roles WHERE name = 'STUDENT'));

-- 2. Create the League
-- Manager: Brett Admin | Location: Rec Center (ID 3)
INSERT IGNORE INTO leagues
(school_id, location_id, user_id, name, description, registration_start, registration_end, league_start, league_end, is_public, type, capacity, capacity_type, fee, status_id)
VALUES
  (1, 3, (SELECT id FROM users WHERE email = 'brett.baumgart@kirkwood.edu'), 'Fall 2026 5v5 Basketball', 'Competitive 5-on-5 men''s basketball league.', '2026-08-01 08:00:00', '2026-08-31 23:59:59', '2026-09-07 18:00:00', '2026-11-15 22:00:00', 1, 'male', 12, 'team', 50.00, 'active');

-- 3. Create the Teams
INSERT IGNORE INTO teams
(league_id, captain_user_id, name, status_id)
VALUES
  ((SELECT id FROM leagues WHERE name = 'Fall 2026 5v5 Basketball' LIMIT 1), (SELECT id FROM users WHERE email = 'alex.student@student.kirkwood.edu'), 'The Eagles', 'active'),
  ((SELECT id FROM leagues WHERE name = 'Fall 2026 5v5 Basketball' LIMIT 1), (SELECT id FROM users WHERE email = 'sam.student@student.kirkwood.edu'), 'Alley-Oops', 'active');

-- 4. Assign Users to Teams (Roster)
INSERT IGNORE INTO team_users
(team_id, user_id, role)
VALUES
  ((SELECT id FROM teams WHERE name = 'The Eagles' LIMIT 1), (SELECT id FROM users WHERE email = 'alex.student@student.kirkwood.edu'), 'captain'),
  ((SELECT id FROM teams WHERE name = 'Alley-Oops' LIMIT 1), (SELECT id FROM users WHERE email = 'sam.student@student.kirkwood.edu'), 'captain');

-- 5. Create an Event (e.g., Week 1 Game Night)
-- Location: Basketball Court 1 (ID 7)
INSERT IGNORE INTO events
(league_id, location_id, user_id, name, description, event_start, event_end, status_id)
VALUES
  ((SELECT id FROM leagues WHERE name = 'Fall 2026 5v5 Basketball' LIMIT 1), 7, (SELECT id FROM users WHERE email = 'brett.baumgart@kirkwood.edu'), 'Week 1 Games', 'Opening night matchups on Court 1', '2026-09-07 18:00:00', '2026-09-07 20:00:00', 'active');

-- 6. Schedule a Match within that Event
INSERT IGNORE INTO matches
(event_id, home_team_id, away_team_id, status)
VALUES
  ((SELECT id FROM events WHERE name = 'Week 1 Games' LIMIT 1), (SELECT id FROM teams WHERE name = 'The Eagles' LIMIT 1), (SELECT id FROM teams WHERE name = 'Alley-Oops' LIMIT 1), 'scheduled');
  • This includes teams and more

Customize messages.properties

NotBlank.league.name=Please provide a unique name for this season's league.
NotNull.league.defaultLocation=Please select a default location for this league.
NotNull.league.locationId=Please select a default location for this league.
NotNull.league.type=Please select the gender format.
NotNull.league.capacity=Please enter a team capacity.
Min.league.capacity=The capacity limit must be at least 1.
NotNull.league.capacityType=Please select the registration type.
NotNull.league.status=League status cannot be blank.
  • Update src/main/resources/messages.properties to customize the league input error messages.

  • Spring Boot automatically generates highly specific error codes based on a formula: AnnotationName.objectName.fieldName

  • YOUR TASK: Create a NotNull message for league fees saying, "Please enter a registration fee (enter 0 for free leagues)." Create a Min message for league fees saying, "The registration fee cannot be negative."

Customize messages.properties

NotBlank.league.name=Please provide a unique name for this season's league.
NotNull.league.defaultLocation=Please select a default location for this league.
NotNull.league.locationId=Please select a default location for this league.
NotNull.league.type=Please select the gender format.
NotNull.league.capacity=Please enter a team capacity.
Min.league.capacity=The capacity limit must be at least 1.
NotNull.league.capacityType=Please select the registration type.
NotNull.league.fee=Please enter a registration fee (enter 0 for free leagues).
Min.league.fee=The registration fee cannot be negative.
NotNull.league.status=League status cannot be blank.
  • SOLUTION

  • Create League.java inside a new league package with the following code.
  • When you declare the isPublic attribute, Lombok will automatically create a getter named isPublic(). However, Thymeleaf will strictly look for a method named getIsPublic() or isIsPublic().
    • The attribute cannot be public because it's a reserved word.
  • YOUR TASK: explicitly add getIsPublic and setIsPublic methods to the League class.

League Entity

package org.springframework.samples.petclinic.league;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.samples.petclinic.model.NamedEntity;
import org.springframework.samples.petclinic.school.Location;
import org.springframework.samples.petclinic.school.School;
import org.springframework.samples.petclinic.user.User;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "leagues")
@Getter
@Setter
@SQLDelete(sql = "UPDATE leagues SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class League extends NamedEntity {

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "school_id", nullable = false)
	private School school;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "location_id")
	private Location defaultLocation;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "user_id")
	private User manager;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "copied_from_id")
	private League copiedFrom;

	@Column(name = "description", columnDefinition = "TEXT")
	private String description;

	@Column(name = "registration_start")
	private LocalDateTime registrationStart;

	@Column(name = "registration_end")
	private LocalDateTime registrationEnd;

	@Column(name = "league_start")
	private LocalDateTime leagueStart;

	@Column(name = "league_end")
	private LocalDateTime leagueEnd;

	@Column(name = "is_public")
	private boolean isPublic = true;

	@Enumerated(EnumType.STRING)
	@Column(name = "type", nullable = false)
	private LeagueType type;

	@Column(name = "capacity")
	private Integer capacity;

	@Enumerated(EnumType.STRING)
	@Column(name = "capacity_type", nullable = false)
	private CapacityType capacityType;

	@Column(name = "fee", precision = 6, scale = 2)
	private BigDecimal fee;

	@Enumerated(EnumType.STRING)
	@Column(name = "status_id")
	private LeagueStatus status = LeagueStatus.DRAFT;

	// Enums
	public enum LeagueType { MALE, FEMALE, COED }
	public enum CapacityType { TEAM, INDIVIDUAL }
	public enum LeagueStatus { DRAFT, ACTIVE, INACTIVE, POSTPONED, CANCELLED, PAST }

	@Column(name = "created_at", insertable = false, updatable = false)
	private LocalDateTime createdAt;

	@Column(name = "updated_at", insertable = false, updatable = false)
	private LocalDateTime updatedAt;

	@Column(name = "deleted_at")
	private LocalDateTime deletedAt;

}
  • SOLUTION
  • Note to future self: I removed private List<Team> teams from the previous slide, because students didn't need it in 2026

League Entity

package org.springframework.samples.petclinic.league;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.samples.petclinic.model.NamedEntity;
import org.springframework.samples.petclinic.school.Location;
import org.springframework.samples.petclinic.school.School;
import org.springframework.samples.petclinic.user.User;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "leagues")
@Getter
@Setter
@SQLDelete(sql = "UPDATE leagues SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class League extends NamedEntity {

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "school_id", nullable = false)
	private School school;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "location_id")
	private Location defaultLocation;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "user_id")
	private User manager;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "copied_from_id")
	private League copiedFrom;

	@Column(name = "description", columnDefinition = "TEXT")
	private String description;

	@Column(name = "registration_start")
	private LocalDateTime registrationStart;

	@Column(name = "registration_end")
	private LocalDateTime registrationEnd;

	@Column(name = "league_start")
	private LocalDateTime leagueStart;

	@Column(name = "league_end")
	private LocalDateTime leagueEnd;

	@Column(name = "is_public")
	private boolean isPublic = true;

	@Enumerated(EnumType.STRING)
	@Column(name = "type", nullable = false)
	private LeagueType type;

	@Column(name = "capacity")
	private Integer capacity;

	@Enumerated(EnumType.STRING)
	@Column(name = "capacity_type", nullable = false)
	private CapacityType capacityType;

	@Column(name = "fee", precision = 6, scale = 2)
	private BigDecimal fee;

	@Enumerated(EnumType.STRING)
	@Column(name = "status_id")
	private LeagueStatus status = LeagueStatus.DRAFT;

	@OneToMany(mappedBy = "league", cascade = CascadeType.ALL, orphanRemoval = true)
	private List<Team> teams = new ArrayList<>();

	// Enums
	public enum LeagueType { MALE, FEMALE, COED }
	public enum CapacityType { TEAM, INDIVIDUAL }
	public enum LeagueStatus { DRAFT, ACTIVE, INACTIVE, POSTPONED, CANCELLED, PAST }

	@Column(name = "created_at", insertable = false, updatable = false)
	private LocalDateTime createdAt;

	@Column(name = "updated_at", insertable = false, updatable = false)
	private LocalDateTime updatedAt;

	@Column(name = "deleted_at")
	private LocalDateTime deletedAt;
    
    public boolean getIsPublic() {
        return this.isPublic;
    }

    public void setIsPublic(boolean isPublic) {
        this.isPublic = isPublic;
    }

}

Update League.java

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;

// ... inside your League class ...

    @NotNull
    @Min(1) 
    @Column(name = "capacity")
    private Integer capacity;
  • YOUR TASK: Update League.java to add the necessary input validation annotations for the type, capacityType, fee, and status attributes.

  • This work will relate to what you added to messages.properties

  • An example for capacity is given.

  • These relate to the following messages:
    NotNull.league.capacity=Please enter a team capacity.
    Min.league.capacity=The capacity limit must be at least 1.

Update League.java

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;

// ... inside your League class ...

    @NotNull
    @Enumerated(EnumType.STRING)
    @Column(name = "type", nullable = false)
    private LeagueType type;

    @NotNull
    @Min(1) 
    @Column(name = "capacity")
    private Integer capacity;

    @NotNull
    @Enumerated(EnumType.STRING)
    @Column(name = "capacity_type", nullable = false)
    private CapacityType capacityType;

    @NotNull
    @Min(0)
    @Column(name = "fee", precision = 6, scale = 2)
    private BigDecimal fee;

    @NotNull
    @Enumerated(EnumType.STRING)
    @Column(name = "status_id")
    private LeagueStatus status = LeagueStatus.DRAFT;

    // ... leave dates and description without @NotNull so Drafts can be saved ...
  • SOLUTION

  • Note to future self: Do not add @NotNull to your four Date fields. If you do, your cloneLeagueForNewSeason method will crash when it attempts to save the newly cloned draft with empty dates!

  • leave dates and description without @NotNull so Drafts can be saved

  • Create Team.java inside the same package.

Team Entity

package org.springframework.samples.petclinic.league;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.samples.petclinic.model.NamedEntity;
import org.springframework.samples.petclinic.user.User;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "teams")
@Getter
@Setter
@SQLDelete(sql = "UPDATE teams SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Team extends NamedEntity {

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "league_id", nullable = false)
	private League league;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "captain_user_id", nullable = false)
	private User captain;

	@Column(name = "logo_url")
	private String logoUrl;

	@Enumerated(EnumType.STRING)
	@Column(name = "status_id")
	private TeamStatus status = TeamStatus.ACTIVE;

	@OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true)
	private List<TeamUser> roster = new ArrayList<>();

	public enum TeamStatus { ACTIVE, INACTIVE, SUSPENDED }

	@Column(name = "created_at", insertable = false, updatable = false)
	private LocalDateTime createdAt;

	@Column(name = "updated_at", insertable = false, updatable = false)
	private LocalDateTime updatedAt;

	@Column(name = "deleted_at")
	private LocalDateTime deletedAt;
}
  • Create TeamUser.java inside the same package.

TeamUser Entity

package org.springframework.samples.petclinic.league;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.samples.petclinic.model.BaseEntity;
import org.springframework.samples.petclinic.user.User;

import java.time.LocalDateTime;

@Entity
@Table(name = "team_users")
@Getter
@Setter
@SQLDelete(sql = "UPDATE team_users SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class TeamUser extends BaseEntity {

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "team_id", nullable = false)
	private Team team;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "user_id", nullable = false)
	private User user;

	@Enumerated(EnumType.STRING)
	@Column(name = "role")
	private RosterRole role = RosterRole.MEMBER;

	public enum RosterRole { MEMBER, CAPTAIN }

	@Column(name = "created_at", insertable = false, updatable = false)
	private LocalDateTime createdAt;

	@Column(name = "updated_at", insertable = false, updatable = false)
	private LocalDateTime updatedAt;

	@Column(name = "deleted_at")
	private LocalDateTime deletedAt;
}
  • Create Event.java inside the same package.
  • It maps the relationships to the league, the location, the organizer, and the matches it contains.

Event Entity

package org.springframework.samples.petclinic.league;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.samples.petclinic.model.NamedEntity;
import org.springframework.samples.petclinic.school.Location;
import org.springframework.samples.petclinic.user.User;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "events")
@Getter
@Setter
@SQLDelete(sql = "UPDATE events SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Event extends NamedEntity {

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "league_id", nullable = false)
	private League league;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "location_id")
	private Location location;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "user_id")
	private User organizer;

	@Column(name = "description", columnDefinition = "TEXT")
	private String description;

	@Column(name = "event_start")
	private LocalDateTime eventStart;

	@Column(name = "event_end")
	private LocalDateTime eventEnd;

	@Enumerated(EnumType.STRING)
	@Column(name = "status_id")
	private EventStatus status = EventStatus.DRAFT;

	@Column(name = "deleted_at")
	private LocalDateTime deletedAt;

	@OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true)
	private List<Match> matches = new ArrayList<>();

	public enum EventStatus { DRAFT, ACTIVE, ONGOING, POSTPONED, CANCELLED, FINAL }

	// Use Lombok @Getter/@Setter or generate standard getters and setters here
}
  • Create Match.java inside the same package.

Match Entity

package org.springframework.samples.petclinic.league;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.samples.petclinic.model.BaseEntity;
import java.time.LocalDateTime;

@Entity
@Table(name = "matches")
@Getter
@Setter
@SQLDelete(sql = "UPDATE matches SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Match extends BaseEntity {

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "event_id", nullable = false)
	private Event event;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "home_team_id")
	private Team homeTeam;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "away_team_id")
	private Team awayTeam;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "winner_team_id")
	private Team winnerTeam;

	@Column(name = "home_score")
	private Integer homeScore = 0;

	@Column(name = "away_score")
	private Integer awayScore = 0;

	@Enumerated(EnumType.STRING)
	@Column(name = "status")
	private MatchStatus status = MatchStatus.SCHEDULED;

	@Column(name = "deleted_at")
	private LocalDateTime deletedAt;

	public enum MatchStatus { SCHEDULED, IN_PROGRESS, FINAL, FORFEIT }

	// Use Lombok @Getter/@Setter or generate standard getters and setters here
}

Complete this column for up to another 30% of your final grade.

  • In the league package, create a Spring Data JPA repository for the League entity.
  • The first method will fetch all leagues for the School Admin
  • The second will filter out past and draft leagues for guests and students.
  • YOUR TASK: Replace the two ???'s with the correct code. See OwnerRepository, PetTypeRepository, or RoleRepository for help.

LeagueRepository.java

package org.springframework.samples.petclinic.league;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;

public interface LeagueRepository extends ??? {
    
    List<League> findBySchoolIdOrderByLeagueStartDesc(Integer schoolId);

    ???("SELECT l FROM League l WHERE l.school.id = :schoolId " +
           "AND l.status <> :draftStatus " +
           "AND (l.leagueEnd IS NULL OR l.leagueEnd > :now) " +
           "ORDER BY l.leagueStart ASC")
    List<League> findActiveLeagues(@Param("schoolId") Integer schoolId, 
                                   @Param("draftStatus") League.LeagueStatus draftStatus, 
                                   @Param("now") LocalDateTime now);
}
  • SOLUTION
  • The first custom method is based on the performance indexes defined in the SQL schema.
  • Reminder to future self: the first two weren't needed for the exam.

LeagueRepository.java

package org.springframework.samples.petclinic.league;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;

public interface LeagueRepository extends JpaRepository<League, Integer> {
	// Matches your idx_leagues_school_status index
	List<League> findBySchoolIdAndStatus(Integer schoolId, League.LeagueStatus status);

	List<League> findBySchoolId(Integer schoolId);
    
    List<League> findBySchoolIdOrderByLeagueStartDesc(Integer schoolId);

    @Query("SELECT l FROM League l WHERE l.school.id = :schoolId " +
           "AND l.status <> :draftStatus " +
           "AND (l.leagueEnd IS NULL OR l.leagueEnd > :now) " +
           "ORDER BY l.leagueStart ASC")
    List<League> findActiveLeagues(@Param("schoolId") Integer schoolId, 
                                   @Param("draftStatus") League.LeagueStatus draftStatus, 
                                   @Param("now") LocalDateTime now);
}

TeamRepository.java

package org.springframework.samples.petclinic.league;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface TeamRepository extends JpaRepository<Team, Integer> {
	// Matches your idx_teams_league index
	List<Team> findByLeagueId(Integer leagueId);
}

TeamUserRepository.java

package org.springframework.samples.petclinic.league;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;

public interface TeamUserRepository extends JpaRepository<TeamUser, Integer> {
	List<TeamUser> findByTeamId(Integer teamId);

	// Enforces the unique user-per-team constraint
	Optional<TeamUser> findByTeamIdAndUserId(Integer teamId, Integer userId);
}

EventRepository.java

package org.springframework.samples.petclinic.league;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface EventRepository extends JpaRepository<Event, Integer> {
	List<Event> findByLeagueId(Integer leagueId);
}

MatchRepository.java

package org.springframework.samples.petclinic.league;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface MatchRepository extends JpaRepository<Match, Integer> {
	List<Match> findByEventId(Integer eventId);

	List<Match> findByHomeTeamIdOrAwayTeamId(Integer homeTeamId, Integer awayTeamId);
}

The League Service

package org.springframework.samples.petclinic.league;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;

@Service
public class LeagueService {

	private final LeagueRepository leagueRepository;

	public LeagueService(LeagueRepository leagueRepository) {
		this.leagueRepository = leagueRepository;
	}

	@Transactional
	public League cloneLeagueForNewSeason(Integer sourceLeagueId) {
		League sourceLeague = leagueRepository.findById(sourceLeagueId)
			.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Source league not found"));

		League newLeague = new League();

		// 1. Link the lineage
		newLeague.setCopiedFrom(sourceLeague);
		newLeague.setSchool(sourceLeague.getSchool());

		// 2. Copy the configuration settings
		newLeague.setName(sourceLeague.getName() + " (Copy)");
		newLeague.setDescription(sourceLeague.getDescription());
		newLeague.setType(sourceLeague.getType());
		newLeague.setCapacity(sourceLeague.getCapacity());
		newLeague.setCapacityType(sourceLeague.getCapacityType());
		newLeague.setFee(sourceLeague.getFee());
		newLeague.setIsPublic(sourceLeague.getIsPublic());

		// Default location and manager can be copied, as they often remain the same
		newLeague.setDefaultLocation(sourceLeague.getDefaultLocation());
		newLeague.setManager(sourceLeague.getManager());

		// 3. Reset state-specific fields for the new season
		newLeague.setStatus(League.LeagueStatus.DRAFT);

		// Dates are left null to force the admin to set the new season's schedule
		newLeague.setRegistrationStart(null);
		newLeague.setRegistrationEnd(null);
		newLeague.setLeagueStart(null);
		newLeague.setLeagueEnd(null);

		// 4. Save and return the new entity
		// Note: Teams and Events are NOT copied, as the new league needs a fresh roster and schedule.
		return leagueRepository.save(newLeague);
	}
}
  • Here is the service logic required to safely clone a league.

  • When a SCHOOL_ADMIN copies a league from a previous year, they will typically want the configuration (rules, fees, capacities) but not the old rosters, matches, or historical dates.
  • Create LeagueService.java. This service handles the transaction of duplicating the league entity while intentionally dropping the historical baggage.

School class

// -- Imports and annotations omitted --

public class School extends NamedEntity {
	
    // -- Code omitted --

	public String getSlug() {
		if (this.domain == null) {
			return "";
		}
		return this.domain.replace(".edu", "");
	}
}
  • Add a getSlug() method to your School.java file. This allows you to access the slug directly in Thymeleaf templates using ${school.slug} rather than ${school.domain.substring(0, school.domain.length() - 4)}, which we used in schoolList.html.

  • We will also use it to redirect "/schools/1" to "/schools/kirkwood".

Update SchoolController

import org.springframework.samples.petclinic.league.League;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDateTime;
import java.util.List;


// ... inside your controller ...

    @GetMapping("/{schoolId:\\d+}")
    public String redirectToSlug(@PathVariable("schoolId") int schoolId) {
        School school = schoolRepository.findById(schoolId)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
    
        return "redirect:/schools/" + school.getSlug();
    }

    @GetMapping("/{slug:[a-zA-Z0-9-]*[a-zA-Z-][a-zA-Z0-9-]*}")
	public ModelAndView showSchoolBySlug(@PathVariable("slug") String slug, Principal principal) {
        // ... existing code omitted ...

        // Fetch the appropriate leagues
		List<League> leagues;
		if (checkEditPermissions(school)) {
			leagues = leagueRepository.findBySchoolIdOrderByLeagueStartDesc(school.getId());
		} else {
			leagues = leagueRepository.findActiveLeagues(school.getId(), League.LeagueStatus.DRAFT, LocalDateTime.now());
		}
        
        mav.addObject("leagues", leagues);

        return mav; 
    }
  • Open your SchoolController.java.

  • YOUR TASK: Inject the LeagueRepository into the constructor.

  • Then, using the code provided, update your @GetMapping method for the school details page to check the user's security context and execute the appropriate query.

  • Delete the showSchool/showSchoolById method that matches "/schools/1" and replace it with a method that redirects to "/schools/kirkwood".

Update SchoolController

import org.springframework.samples.petclinic.league.League;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDateTime;
import java.util.List;

@Controller
@RequestMapping("/schools")
public class SchoolController {
	private final SchoolRepository schoolRepository;
	private final UserRepository userRepository;
	private final LeagueRepository leagueRepository;

	public SchoolController(SchoolRepository schoolRepository, UserRepository userRepository, LeagueRepository leagueRepository) {
		this.schoolRepository = schoolRepository;
		this.userRepository = userRepository;
		this.leagueRepository = leagueRepository;
	}

// ... inside your controller ...

    @GetMapping("/{schoolId:\\d+}")
    public String redirectToSlug(@PathVariable("schoolId") int schoolId) {
        School school = schoolRepository.findById(schoolId)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
    
        return "redirect:/schools/" + school.getSlug();
    }

    @GetMapping("/{slug:[a-zA-Z0-9-]*[a-zA-Z-][a-zA-Z0-9-]*}")
	public ModelAndView showSchoolBySlug(@PathVariable("slug") String slug, Principal principal) {
        // ... existing code omitted ...

        // Fetch the appropriate leagues
		List<League> leagues;
		if (checkEditPermissions(school)) {
			leagues = leagueRepository.findBySchoolIdOrderByLeagueStartDesc(school.getId());
		} else {
			leagues = leagueRepository.findActiveLeagues(school.getId(), League.LeagueStatus.DRAFT, LocalDateTime.now());
		}
        
        mav.addObject("leagues", leagues);

        return mav; 
    }
  • SOLUTION

Update schoolDetails.html

  <div class="d-flex justify-content-between align-items-center">
    <h2>Intramural Leagues</h2>
    <a th:href="@{|/schools/${school.id}/leagues/new|}"
       th:if="${canEdit}"
       class="btn btn-primary mt-0">Create League</a>
  </div>

  <table th:if="${!leagues.isEmpty()}" class="table table-striped table-hover mt-3">
    <thead>
      <tr>
        <th>League Name</th>
        <th>Type</th>
        <th>Registration Deadline</th>
        <th>Status</th>
        <th>Actions</th>
      </tr>
    </thead>
    <tbody>
      <tr th:each="league : ${leagues}">
        <td th:text="${league.name}">League Name</td>
        
        <td th:text="${#strings.capitalize(league.type.name().toLowerCase())}">Coed</td>
        
        <td th:text="${league.registrationEnd != null ? #temporals.format(league.registrationEnd, 'MMM dd, yyyy') : 'TBD'}">Oct 31</td>
        
        <td>
          <span th:text="${league.status}" 
                class="badge"
                th:classappend="${league.status.name() == 'DRAFT' ? 'bg-warning text-dark' : 'bg-success'}">
            ACTIVE
          </span>
        </td>
        
        <td>
          <a th:href="@{|/schools/${school.id}/leagues/${league.id}|}" class="btn btn-sm btn-outline-primary">View Info</a>
        </td>
      </tr>
    </tbody>
  </table>
  • Open schoolDetails.html.

  • Replace the following crossed out lines with the code below to display leagues. This includes Thymeleaf logic to format the dates and apply conditional CSS badges based on the league's status.
    <div class="card shadow-sm h-100">
         <div class="card-body text-center">
             <!-- icon, heading, and paragraph omitted -->
         </div>
    </div>

  • YOUR TASK: Add another block that displays "There are no active leagues at this time." if the list of leagues is empty.

Update schoolDetails.html

  <div class="d-flex justify-content-between align-items-center">
    <h2>Intramural Leagues</h2>
    <a th:href="@{|/schools/${school.id}/leagues/new|}"
       th:if="${canEdit}"
       class="btn btn-primary mt-0">Create League</a>
  </div>

  <table th:if="${!leagues.isEmpty()}" class="table table-striped table-hover mt-3">
    <thead>
      <tr>
        <th>League Name</th>
        <th>Type</th>
        <th>Registration Deadline</th>
        <th>Status</th>
        <th>Actions</th>
      </tr>
    </thead>
    <tbody>
      <tr th:each="league : ${leagues}">
        <td th:text="${league.name}">League Name</td>
        
        <td th:text="${#strings.capitalize(league.type.name().toLowerCase())}">Coed</td>
        
        <td th:text="${league.registrationEnd != null ? #temporals.format(league.registrationEnd, 'MMM dd, yyyy') : 'TBD'}">Oct 31</td>
        
        <td>
          <span th:text="${league.status}" 
                class="badge"
                th:classappend="${league.status.name() == 'DRAFT' ? 'bg-warning text-dark' : 'bg-success'}">
            ACTIVE
          </span>
        </td>
        
        <td>
          <a th:href="@{|/schools/${school.id}/leagues/${league.id}|}" class="btn btn-sm btn-outline-primary">View Info</a>
        </td>
      </tr>
    </tbody>
  </table>

  <p th:if="${leagues.isEmpty()}" class="text-muted mt-3">There are no active leagues at this time.</p>
  • SOLUTION

Checkpoint

Java 3 - Final Exam - Part 1

By Marc Hauschildt

Java 3 - Final Exam - Part 1

  • 57