diff --git a/.env b/.env new file mode 100644 index 00000000..a78d130b --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +MYSQL_HOST=db +KEYSTORE_PATH=./testCert.p12 +KEYSTORE_PASSWORD=123456 +WIQ_IMAGE=pelayori/wiq_es04b \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b976618..be2e94b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,8 +8,74 @@ on: types: [published] jobs: + app-tests-analyze: + runs-on: ubuntu-latest + services: + mysql: + image: mysql:latest + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: test_database + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - uses: actions/checkout@v2 + + - name: Cache Maven packages + uses: actions/cache@v2 + with: + path: | + ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2 + + - name: Set up JDK 17 + uses: actions/setup-java@v1 + with: + java-version: '17' + + - name: Add exec permission to mvnw + run: chmod +x mvnw + + - name: Compile the application + run: ./mvnw -B clean install -DskipTests=true + + - name: Start the application + run: | + ./mvnw spring-boot:run > logs.txt 2>&1 & + echo $! > spring-boot-app.pid + sleep 5 + env: + SPRING_DATASOURCE_URL: jdbc:mysql://localhost:3306/test_database + SPRING_DATASOURCE_USERNAME: root + SPRING_DATASOURCE_PASSWORD: root + SPRING_DATASOURCE_DRIVER_CLASS_NAME: com.mysql.cj.jdbc.Driver + SPRING_PROFILES_ACTIVE: test + - name: Check listening ports + run: ss -tuln + - name: Run tests + run: | + ./mvnw org.jacoco:jacoco-maven-plugin:prepare-agent verify -Dspring.datasource.url=jdbc:mysql://localhost:3306/test_database -Dspring.datasource.username=root -Dspring.datasource.password=root -Dspring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + env: + SPRING_PROFILES_ACTIVE: test + headless: true + EXCLUDE_JUNIT: true + - name: Show app logs + if: always() + run: | + cat logs.txt + - name: Shut down the application + run: | + kill $(cat spring-boot-app.pid) + - name: Collect Jacoco report and send to Sonar + run: | + ./mvnw org.jacoco:jacoco-maven-plugin:report sonar:sonar -Dsonar.projectKey=Arquisoft_wiq_es04b -Dsonar.organization=arquisoft -Dsonar.branch.name=${{ github.head_ref || github.ref_name }} -Dsonar.host.url=https://sonarcloud.io -Dsonar.login=${{ secrets.SONAR_TOKEN }} -Dspring.profiles.active=test build-and-push: runs-on: ubuntu-latest + needs: app-tests-analyze steps: - uses: actions/checkout@v4 @@ -43,7 +109,8 @@ jobs: username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_KEY }} script: | - wget https://raw.githubusercontent.com/${{ github.repository }}/${{ github.ref_name }}/docker-compose.yml -O docker-compose.yml docker-compose down + wget https://raw.githubusercontent.com/${{ github.repository }}/${{ github.ref_name }}/docker-compose.yml -O docker-compose.yml + wget https://raw.githubusercontent.com/${{ github.repository }}/${{ github.ref_name }}/prometheus.yml -O prometheus.yml docker compose pull docker-compose up -d diff --git a/.github/workflows/unit-tests-push.yml b/.github/workflows/unit-tests-push.yml index 7391216e..7b8cf4a7 100644 --- a/.github/workflows/unit-tests-push.yml +++ b/.github/workflows/unit-tests-push.yml @@ -74,4 +74,4 @@ jobs: kill $(cat spring-boot-app.pid) - name: Collect Jacoco report and send to Sonar run: | - ./mvnw org.jacoco:jacoco-maven-plugin:report sonar:sonar -Dsonar.projectKey=Arquisoft_wiq_es04b -Dsonar.organization=arquisoft -Dsonar.branch.name=${{ github.head_ref || github.ref_name }} -Dsonar.host.url=https://sonarcloud.io -Dsonar.login=${{ secrets.SONAR_TOKEN }} -Dspring.profiles.active=test \ No newline at end of file + ./mvnw org.jacoco:jacoco-maven-plugin:report sonar:sonar -Dsonar.projectKey=Arquisoft_wiq_es04b -Dsonar.organization=arquisoft -Dsonar.branch.name=${{ github.head_ref || github.ref_name }} -Dsonar.host.url=https://sonarcloud.io -Dsonar.login=${{ secrets.SONAR_TOKEN }} -Dspring.profiles.active=test diff --git a/Dockerfile b/Dockerfile index 752ecea9..0edd0077 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Build stage with Maven and JDK 17 FROM maven:3.8.4-openjdk-17-slim as build -WORKDIR /app +WORKDIR ./app COPY pom.xml . COPY src src/ # Use Maven directly instead of the Maven Wrapper @@ -8,6 +8,6 @@ RUN mvn clean package -DskipTests # Run stage with JDK 17 FROM openjdk:17-slim -COPY --from=build /app/target/*.jar app.jar +COPY --from=build ./app/target/*.jar app.jar ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-Dspring.profiles.active=prod","-jar","/app.jar"] EXPOSE 443 \ No newline at end of file diff --git a/README.md b/README.md index 3568d977..830b17be 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,34 @@ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Arquisoft_wiq_es04b&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=Arquisoft_wiq_es04b) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=Arquisoft_wiq_es04b&metric=coverage)](https://sonarcloud.io/summary/new_code?id=Arquisoft_wiq_es04b) -### 🚀 TEAM +### 🚀 TEAM: - **Pelayo Rojas Iñigo** - **Álvaro Arias MartĂ­nez De Vega** - **Ricardo DĂ­az NĂșñez** - **Roberto Peña Goy** - **Iker Álvarez FernĂĄndez** + +### Local deployment instructions: + +#### Without docker (slower): + +1. Fist you have to clone the repository using a CMD and the following command: `git clone https://github.com/Arquisoft/wiq_es04b.git` or using an IDE with Git integration or any other app of your preference. + +2. Then you have to execute the [runServer.bat](https://github.com/Arquisoft/wiq_es04b/blob/master/database/hsqldb/bin/runServer.bat) to start the local database. + +3. With the database initialized you have to open a CMD in the project root directory and execute the following command `mvnw spring-boot:run`, to start the application. + +4. When the application is started the web app uses the port 3000. You can access the app through any web client using the following URL: http://localhost:3000/. + +5. If you wish to execute the tests you have to open a CMD in the project root directory (you could use the same you used before), you have to execute `set EXCLUDE_JUNIT=true` if you also want to execute the E2E tests. Then to execute the tests you have to use the following command: `mvnw org.jacoco:jacoco-maven-plugin:prepare-agent verify`. + +6. If you want to obtain the report you have to torn off the app and in the same CMD as before execute the following command: `mvnw org.jacoco:jacoco-maven-plugin:report`. + +#### With docker (faster): + +> #### *Disclaimer: This method is faster but it is not recommended for development because it is harder to debug and to see the logs and it is harder to execute the tests.* + +1. First you need to have installed [docker](https://www.docker.com/#build) and docker [compose](https://docs.docker.com/compose/install/). +2. Then you have to clone the repository using a CMD and the following command: `git clone https://github.com/Arquisoft/wiq_es04b.git` or using an IDE with Git integration or any other app of your preference. +3. Then you have to open a CMD in the project root directory and execute the following command: `docker-compose up`. This is going to deploy the docker image that is in our repository. This docker will contain the app, a MySql database, Graphana and Prometheus. The app will be available in the port 443 https. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a61915cf..e88e89ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,13 +18,13 @@ services: restart: always wiq_es04b: - image: ghcr.io/arquisoft/wiq_es04b:latest + image: ${WIQ_IMAGE:-ghcr.io/arquisoft/wiq_es04b:latest} environment: MYSQL_HOST: ${MYSQL_HOST} - MYSQL_PORT: ${MYSQL_PORT} - MYSQL_DATABASE: ${MYSQL_DATABASE} - MYSQL_USER: ${MYSQL_USER} - MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE:-wiq_es04b} + MYSQL_USER: ${MYSQL_USER:-wiq_es04b} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-wiq_es04b} + MYSQL_PORT: ${MYSQL_PORT:-3306} KEYSTORE_PATH: ${KEYSTORE_PATH} KEYSTORE_PASSWORD: ${KEYSTORE_PASSWORD} ports: @@ -38,9 +38,40 @@ services: volumes: - ${KEYSTORE_PATH}:/certs/keystore.p12 + prometheus: + image: prom/prometheus:latest + volumes: + - prometheus_data:/prometheus + - ./prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + networks: + - mynetwork + depends_on: + - wiq_es04b + restart: always + + grafana: + image: grafana/grafana:latest + volumes: + - grafana_data:/var/lib/grafana + environment: + GF_AUTH_DISABLE_LOGIN_FORM: 1 + GF_AUTH_ANONYMOUS_ENABLED: true + GF_AUTH_ANONYMOUS_ORG_ROLE: Admin + ports: + - "3000:3000" + networks: + - mynetwork + depends_on: + - prometheus + restart: always + volumes: db_data: + prometheus_data: + grafana_data: networks: mynetwork: - driver: bridge + driver: bridge \ No newline at end of file diff --git a/docs/images/ChangeLanguage/1000UsersLanguage.png b/docs/images/ChangeLanguage/1000UsersLanguage.png new file mode 100644 index 00000000..37b6a869 Binary files /dev/null and b/docs/images/ChangeLanguage/1000UsersLanguage.png differ diff --git a/docs/images/ChangeLanguage/1UserLanguage.png b/docs/images/ChangeLanguage/1UserLanguage.png new file mode 100644 index 00000000..077b16d2 Binary files /dev/null and b/docs/images/ChangeLanguage/1UserLanguage.png differ diff --git a/docs/images/ChangeLanguage/2000UsersLanguage.png b/docs/images/ChangeLanguage/2000UsersLanguage.png new file mode 100644 index 00000000..1fd80d77 Binary files /dev/null and b/docs/images/ChangeLanguage/2000UsersLanguage.png differ diff --git a/docs/images/ChangeLanguage/250UsersLanguage.png b/docs/images/ChangeLanguage/250UsersLanguage.png new file mode 100644 index 00000000..dd3542d9 Binary files /dev/null and b/docs/images/ChangeLanguage/250UsersLanguage.png differ diff --git a/docs/images/ChangeLanguage/5000UsersLanguage.png b/docs/images/ChangeLanguage/5000UsersLanguage.png new file mode 100644 index 00000000..df2f3d24 Binary files /dev/null and b/docs/images/ChangeLanguage/5000UsersLanguage.png differ diff --git a/docs/images/ChangeLanguage/GeneralLanguage.png b/docs/images/ChangeLanguage/GeneralLanguage.png new file mode 100644 index 00000000..8f026464 Binary files /dev/null and b/docs/images/ChangeLanguage/GeneralLanguage.png differ diff --git a/docs/images/ChangeLanguage/GraphicLanguage.png b/docs/images/ChangeLanguage/GraphicLanguage.png new file mode 100644 index 00000000..edb2944f Binary files /dev/null and b/docs/images/ChangeLanguage/GraphicLanguage.png differ diff --git a/docs/images/GlobalRanking/1000UsersGR.png b/docs/images/GlobalRanking/1000UsersGR.png new file mode 100644 index 00000000..83a90a72 Binary files /dev/null and b/docs/images/GlobalRanking/1000UsersGR.png differ diff --git a/docs/images/GlobalRanking/1UserGR.png b/docs/images/GlobalRanking/1UserGR.png new file mode 100644 index 00000000..a654ec53 Binary files /dev/null and b/docs/images/GlobalRanking/1UserGR.png differ diff --git a/docs/images/GlobalRanking/2000UsersGR.png b/docs/images/GlobalRanking/2000UsersGR.png new file mode 100644 index 00000000..d8781da7 Binary files /dev/null and b/docs/images/GlobalRanking/2000UsersGR.png differ diff --git a/docs/images/GlobalRanking/250UsersGR.png b/docs/images/GlobalRanking/250UsersGR.png new file mode 100644 index 00000000..2ef7b1c5 Binary files /dev/null and b/docs/images/GlobalRanking/250UsersGR.png differ diff --git a/docs/images/GlobalRanking/5000UsersGR.png b/docs/images/GlobalRanking/5000UsersGR.png new file mode 100644 index 00000000..238075d0 Binary files /dev/null and b/docs/images/GlobalRanking/5000UsersGR.png differ diff --git a/docs/images/GlobalRanking/GeneralGR.png b/docs/images/GlobalRanking/GeneralGR.png new file mode 100644 index 00000000..de43e991 Binary files /dev/null and b/docs/images/GlobalRanking/GeneralGR.png differ diff --git a/docs/images/GlobalRanking/GraphicGR.png b/docs/images/GlobalRanking/GraphicGR.png new file mode 100644 index 00000000..5a97eff2 Binary files /dev/null and b/docs/images/GlobalRanking/GraphicGR.png differ diff --git a/docs/images/Index/1000UsersIndex.png b/docs/images/Index/1000UsersIndex.png new file mode 100644 index 00000000..e9d12a53 Binary files /dev/null and b/docs/images/Index/1000UsersIndex.png differ diff --git a/docs/images/Index/1UserIndex.png b/docs/images/Index/1UserIndex.png new file mode 100644 index 00000000..b7ce68c1 Binary files /dev/null and b/docs/images/Index/1UserIndex.png differ diff --git a/docs/images/Index/2000UsersIndex.png b/docs/images/Index/2000UsersIndex.png new file mode 100644 index 00000000..015aa68a Binary files /dev/null and b/docs/images/Index/2000UsersIndex.png differ diff --git a/docs/images/Index/250UsersIndex.png b/docs/images/Index/250UsersIndex.png new file mode 100644 index 00000000..436cabac Binary files /dev/null and b/docs/images/Index/250UsersIndex.png differ diff --git a/docs/images/Index/5000UsersIndex.png b/docs/images/Index/5000UsersIndex.png new file mode 100644 index 00000000..ccee9aa1 Binary files /dev/null and b/docs/images/Index/5000UsersIndex.png differ diff --git a/docs/images/Index/GeneralIndex.png b/docs/images/Index/GeneralIndex.png new file mode 100644 index 00000000..c71de99e Binary files /dev/null and b/docs/images/Index/GeneralIndex.png differ diff --git a/docs/images/Index/GraphicIndex.png b/docs/images/Index/GraphicIndex.png new file mode 100644 index 00000000..7b5c14fc Binary files /dev/null and b/docs/images/Index/GraphicIndex.png differ diff --git a/docs/images/LogOut/1000UsersLogout.png b/docs/images/LogOut/1000UsersLogout.png new file mode 100644 index 00000000..9c88e4b2 Binary files /dev/null and b/docs/images/LogOut/1000UsersLogout.png differ diff --git a/docs/images/LogOut/1UserLogout.png b/docs/images/LogOut/1UserLogout.png new file mode 100644 index 00000000..cc74e475 Binary files /dev/null and b/docs/images/LogOut/1UserLogout.png differ diff --git a/docs/images/LogOut/2000UsersLogout.png b/docs/images/LogOut/2000UsersLogout.png new file mode 100644 index 00000000..1fd2b3ae Binary files /dev/null and b/docs/images/LogOut/2000UsersLogout.png differ diff --git a/docs/images/LogOut/250UsersLogout.png b/docs/images/LogOut/250UsersLogout.png new file mode 100644 index 00000000..251d2d05 Binary files /dev/null and b/docs/images/LogOut/250UsersLogout.png differ diff --git a/docs/images/LogOut/5000UsersLogout.png b/docs/images/LogOut/5000UsersLogout.png new file mode 100644 index 00000000..57d5c44b Binary files /dev/null and b/docs/images/LogOut/5000UsersLogout.png differ diff --git a/docs/images/LogOut/GeneralLogout.png b/docs/images/LogOut/GeneralLogout.png new file mode 100644 index 00000000..0ba5edba Binary files /dev/null and b/docs/images/LogOut/GeneralLogout.png differ diff --git a/docs/images/LogOut/GraphicLogout.png b/docs/images/LogOut/GraphicLogout.png new file mode 100644 index 00000000..1451107b Binary files /dev/null and b/docs/images/LogOut/GraphicLogout.png differ diff --git a/docs/images/Login/1000UsersLogin.png b/docs/images/Login/1000UsersLogin.png new file mode 100644 index 00000000..a5b3a37c Binary files /dev/null and b/docs/images/Login/1000UsersLogin.png differ diff --git a/docs/images/Login/1UserLogin.png b/docs/images/Login/1UserLogin.png new file mode 100644 index 00000000..1c52edca Binary files /dev/null and b/docs/images/Login/1UserLogin.png differ diff --git a/docs/images/Login/2000UsersLogin.png b/docs/images/Login/2000UsersLogin.png new file mode 100644 index 00000000..e1484a99 Binary files /dev/null and b/docs/images/Login/2000UsersLogin.png differ diff --git a/docs/images/Login/250UsersLogin.png b/docs/images/Login/250UsersLogin.png new file mode 100644 index 00000000..5b97b1da Binary files /dev/null and b/docs/images/Login/250UsersLogin.png differ diff --git a/docs/images/Login/5000UsersLogin.png b/docs/images/Login/5000UsersLogin.png new file mode 100644 index 00000000..ed40cc0b Binary files /dev/null and b/docs/images/Login/5000UsersLogin.png differ diff --git a/docs/images/Login/GeneralLogin.png b/docs/images/Login/GeneralLogin.png new file mode 100644 index 00000000..0efd32d5 Binary files /dev/null and b/docs/images/Login/GeneralLogin.png differ diff --git a/docs/images/Login/GraphicLogin.png b/docs/images/Login/GraphicLogin.png new file mode 100644 index 00000000..4510d014 Binary files /dev/null and b/docs/images/Login/GraphicLogin.png differ diff --git a/docs/images/PersonalRanking/1000UsersPR.png b/docs/images/PersonalRanking/1000UsersPR.png new file mode 100644 index 00000000..06ed7f77 Binary files /dev/null and b/docs/images/PersonalRanking/1000UsersPR.png differ diff --git a/docs/images/PersonalRanking/1UserPR.png b/docs/images/PersonalRanking/1UserPR.png new file mode 100644 index 00000000..88e0774b Binary files /dev/null and b/docs/images/PersonalRanking/1UserPR.png differ diff --git a/docs/images/PersonalRanking/2000UsersPR.png b/docs/images/PersonalRanking/2000UsersPR.png new file mode 100644 index 00000000..d611cd26 Binary files /dev/null and b/docs/images/PersonalRanking/2000UsersPR.png differ diff --git a/docs/images/PersonalRanking/250UsersPR.png b/docs/images/PersonalRanking/250UsersPR.png new file mode 100644 index 00000000..abb8b991 Binary files /dev/null and b/docs/images/PersonalRanking/250UsersPR.png differ diff --git a/docs/images/PersonalRanking/5000UsersPR.png b/docs/images/PersonalRanking/5000UsersPR.png new file mode 100644 index 00000000..b87e85c8 Binary files /dev/null and b/docs/images/PersonalRanking/5000UsersPR.png differ diff --git a/docs/images/PersonalRanking/GeneralPR.png b/docs/images/PersonalRanking/GeneralPR.png new file mode 100644 index 00000000..5d400461 Binary files /dev/null and b/docs/images/PersonalRanking/GeneralPR.png differ diff --git a/docs/images/PersonalRanking/GraphicPR.png b/docs/images/PersonalRanking/GraphicPR.png new file mode 100644 index 00000000..b8c870d5 Binary files /dev/null and b/docs/images/PersonalRanking/GraphicPR.png differ diff --git a/docs/images/PlayGame/1000UsersGame.png b/docs/images/PlayGame/1000UsersGame.png new file mode 100644 index 00000000..c135f7a7 Binary files /dev/null and b/docs/images/PlayGame/1000UsersGame.png differ diff --git a/docs/images/PlayGame/1UserGame.png b/docs/images/PlayGame/1UserGame.png new file mode 100644 index 00000000..07a62dcf Binary files /dev/null and b/docs/images/PlayGame/1UserGame.png differ diff --git a/docs/images/PlayGame/2000UsersGame.png b/docs/images/PlayGame/2000UsersGame.png new file mode 100644 index 00000000..f067fa60 Binary files /dev/null and b/docs/images/PlayGame/2000UsersGame.png differ diff --git a/docs/images/PlayGame/250UsersGame.png b/docs/images/PlayGame/250UsersGame.png new file mode 100644 index 00000000..d4ed2791 Binary files /dev/null and b/docs/images/PlayGame/250UsersGame.png differ diff --git a/docs/images/PlayGame/5000UsersGame.png b/docs/images/PlayGame/5000UsersGame.png new file mode 100644 index 00000000..16493bf7 Binary files /dev/null and b/docs/images/PlayGame/5000UsersGame.png differ diff --git a/docs/images/PlayGame/GeneralGame.png b/docs/images/PlayGame/GeneralGame.png new file mode 100644 index 00000000..906b8c41 Binary files /dev/null and b/docs/images/PlayGame/GeneralGame.png differ diff --git a/docs/images/PlayGame/GraphicGame.png b/docs/images/PlayGame/GraphicGame.png new file mode 100644 index 00000000..a1a798f9 Binary files /dev/null and b/docs/images/PlayGame/GraphicGame.png differ diff --git a/docs/images/ShowApiKey/1000UsersApiKey.png b/docs/images/ShowApiKey/1000UsersApiKey.png new file mode 100644 index 00000000..89b34831 Binary files /dev/null and b/docs/images/ShowApiKey/1000UsersApiKey.png differ diff --git a/docs/images/ShowApiKey/1UserApiKey.png b/docs/images/ShowApiKey/1UserApiKey.png new file mode 100644 index 00000000..4fd58d79 Binary files /dev/null and b/docs/images/ShowApiKey/1UserApiKey.png differ diff --git a/docs/images/ShowApiKey/2000UsersApiKey.png b/docs/images/ShowApiKey/2000UsersApiKey.png new file mode 100644 index 00000000..be58bced Binary files /dev/null and b/docs/images/ShowApiKey/2000UsersApiKey.png differ diff --git a/docs/images/ShowApiKey/250UsersApiKey.png b/docs/images/ShowApiKey/250UsersApiKey.png new file mode 100644 index 00000000..aa133685 Binary files /dev/null and b/docs/images/ShowApiKey/250UsersApiKey.png differ diff --git a/docs/images/ShowApiKey/5000UsersApiKey.png b/docs/images/ShowApiKey/5000UsersApiKey.png new file mode 100644 index 00000000..93304a6e Binary files /dev/null and b/docs/images/ShowApiKey/5000UsersApiKey.png differ diff --git a/docs/images/ShowApiKey/GeneralApiKey.png b/docs/images/ShowApiKey/GeneralApiKey.png new file mode 100644 index 00000000..482de671 Binary files /dev/null and b/docs/images/ShowApiKey/GeneralApiKey.png differ diff --git a/docs/images/ShowApiKey/GraphicApiKey.png b/docs/images/ShowApiKey/GraphicApiKey.png new file mode 100644 index 00000000..6e36e825 Binary files /dev/null and b/docs/images/ShowApiKey/GraphicApiKey.png differ diff --git a/docs/images/ShowProfile/1000UsersProfile.png b/docs/images/ShowProfile/1000UsersProfile.png new file mode 100644 index 00000000..988d92e1 Binary files /dev/null and b/docs/images/ShowProfile/1000UsersProfile.png differ diff --git a/docs/images/ShowProfile/1UserProfile.png b/docs/images/ShowProfile/1UserProfile.png new file mode 100644 index 00000000..039e901e Binary files /dev/null and b/docs/images/ShowProfile/1UserProfile.png differ diff --git a/docs/images/ShowProfile/2000UsersProfile.png b/docs/images/ShowProfile/2000UsersProfile.png new file mode 100644 index 00000000..1d59d8e5 Binary files /dev/null and b/docs/images/ShowProfile/2000UsersProfile.png differ diff --git a/docs/images/ShowProfile/250UsersProfile.png b/docs/images/ShowProfile/250UsersProfile.png new file mode 100644 index 00000000..a4b91c9a Binary files /dev/null and b/docs/images/ShowProfile/250UsersProfile.png differ diff --git a/docs/images/ShowProfile/5000UsersProfile.png b/docs/images/ShowProfile/5000UsersProfile.png new file mode 100644 index 00000000..e0c1ebc8 Binary files /dev/null and b/docs/images/ShowProfile/5000UsersProfile.png differ diff --git a/docs/images/ShowProfile/GeneralProfile.png b/docs/images/ShowProfile/GeneralProfile.png new file mode 100644 index 00000000..0f911ac5 Binary files /dev/null and b/docs/images/ShowProfile/GeneralProfile.png differ diff --git a/docs/images/ShowProfile/GraphicProfile.png b/docs/images/ShowProfile/GraphicProfile.png new file mode 100644 index 00000000..08fbc292 Binary files /dev/null and b/docs/images/ShowProfile/GraphicProfile.png differ diff --git a/docs/index.adoc b/docs/index.adoc index 35d90f14..2d9f23ab 100644 --- a/docs/index.adoc +++ b/docs/index.adoc @@ -7,8 +7,8 @@ include::src/config.adoc[] = image:wiq-logo.png[wiq-image] WIQ es04b -:revnumber: 2 -:revdate: March 2024 +:revnumber: 3 +:revdate: April 2024 :revremark: ASW - wiq_es04b // toc-title definition MUST follow document title without blank line! :toc-title: Table of Contents @@ -101,4 +101,12 @@ include::src/11_technical_risks.adoc[] // 12. Glossary include::src/12_glossary.adoc[] +<<<< +// 13. Question Generation Strategy +include::src/13_question_generation_strategy.adoc[] + +<<<< +// 14. Testing +include::src/14_testing.adoc[] + diff --git a/docs/src/01_introduction_and_goals.adoc b/docs/src/01_introduction_and_goals.adoc index 2f94fe4c..1e53223e 100644 --- a/docs/src/01_introduction_and_goals.adoc +++ b/docs/src/01_introduction_and_goals.adoc @@ -28,7 +28,7 @@ The overall goal of the application is to provide a fun and challenging experien === Quality Goals [options="header",cols="1,2,2"] |=== -|NÂș|Atributo|Motivacion +|NÂș|Quality Goal|Motivation | 1 | Efficiency | Access, creation of questions, and navigation between them should be fast to ensure user satisfaction. | 2 | Usability | The application should be appealing to all fans of the original program while also offering a wide variety of questions. | 3 | Manteinance | The application should ensure easy expansion and modification to provide users with new features. diff --git a/docs/src/03_system_scope_and_context.adoc b/docs/src/03_system_scope_and_context.adoc index f5eea3f2..8e4b5b47 100644 --- a/docs/src/03_system_scope_and_context.adoc +++ b/docs/src/03_system_scope_and_context.adoc @@ -9,62 +9,116 @@ ifndef::imagesdir[:imagesdir: ../images] @startuml actor Player as user actor "External Developers" as developers -component System [ +component WebApp [ <> - QuestionGame + Web game ] -component WikiData as wikidata -user -down-> System : Uses -System -left-> wikidata : Retrieves\nInformation to\nform questions\nand answers -developers -down-> System : Asks for Player\n& Question Data -System -----> developers : Provides\nPlayer &\n Question Data +component PlayerService [ + <> + Player Management +] + +component QuestionService [ + <> + Question Management +] + +component WikiData [ + <> + Wikidata +] + +user ...> WebApp: Plays +WebApp ---> PlayerService : Handles\nlogin and signup +WebApp -left-> QuestionService : Retrieves generated\nquestions for game +WebApp <-- WikiData : Retrieves\nInformation to\nform questions\nand answers +developers .right.> PlayerService : Modify\nplayer data +developers <.right. PlayerService : Retrieve\nplayer data +developers .right.> QuestionService : Modify questions +developers <.right. QuestionService : Retrieve question list @enduml ---- * **Player:** Represents users who interact with the application to play games or view their history. -* **System (QuestionGame):** The main system that hosts the game logic and manages user interactions. +* **Question management:** The sytem that retrieves the questions generated from Wikidata. +* **Player management:** The system that manages player data, such as registration, login, and game history. * **WikiData:** Component used to retrieve data from Wikidata and automatically generate questions. -* **Database:** Stores system information, such as user data, generated questions, game history, etc. -* **External Developers:** Represents developers who access the system's REST API to retrieve player and question data. +* **External Developers:** Represents developers who access the system to retrieve player and question data. === Technical Context [plantuml,"Technical Context Diagram",png] ---- - actor user - person developer - database Database - cloud WikiData - - - node "User Agent" - - node "QuestionGame server" - - user --> [User Agent] - [User Agent] --> [QuestionGame server]: HTTPS - - [QuestionGame server] --> Database: Specific driver - [QuestionGame server] --> WikiData: HTTP - developer --> [QuestionGame server] : HTTPS +actor Player as user +actor "External Developers" as developers +component WebApp [ + <> + Web game +] + +component PlayerService [ + <> + PlayerService +] + +component QuestionService [ + <> + QuestionService +] + +component WikiData [ + <> + Wikidata +] + +component QuestionGenerator [ + <> + QuestionGenerator +] + +component RestApiService [ + <> + RestApiService +] + +database Database [ + Database +] + +user .left.> WebApp: HTTPS +WebApp ---> PlayerService : Handles\nlogin, signup\nand game history +WebApp -left-> QuestionService : Retrieves generated\nquestions for game +QuestionGenerator <-up- WikiData : Get entity info\nfrom SPARQL HTTP +QuestionGenerator --> QuestionService : Generate questions\nwith entity data +QuestionService --> Database: Save\nquestion\nentities +QuestionService <-- Database: Retrieve\nsaved\nquestions +developers .up---.> RestApiService : HTTP\nPOST\nPUT\nDELETE +developers <.up. RestApiService : HTTP GET +RestApiService -up-> QuestionService: Retrieve/Modify questions +RestApiService ---> PlayerService: Retrieve/Modify player data + ---- -* **User Agent:** Represents the web or mobile interface used by users. -* **QuestionGame server:** The server-side components, including the Frontend, QuestionGame logic, User's API, and Question's API. -* **HTTPS:** Represents the communication channels, with HTTPS being the protocol used for secure communication. -* **Question Generation:** Represents the means used for question and answers generation. -* **Database:** Represents whichever system used for data persistence. -* **To be decided:** Indicates that specific details about the channels and protocols are yet to be determined. +* **Player:** Represents users who interact with the application to play games or view their history. +* **Question service:** The system that retrieves the questions generated from Wikidata. +* **Player service:** The system that manages player data, such as registration, login, and game history. +* **WikiData:** Component used to retrieve data from Wikidata +* **Question generator:** The system that generates questions based on the data retrieved from Wikidata. +* **External Developers:** Represents developers who access the system to retrieve player and question data. +* **Database:** The database that stores player data, game history, and questions. +* **Rest API Service:** The service that provides an API for external developers to access player and question data. ==== Input/Output Mapping Table [options="header",cols="1,2,2"] |=== |Component|Input/Output|Channel/Protocol -| User's API| User registration, game history| HTTPS -| Question's API| Question data retrieval| HTTPS +| RestApiService| External developer interactions| HTTPS | Frontend| User interactions, game display| HTTPS | Database| User data, game history, questions| Specific database driver | WikiData| Data for question generation| HTTP +| Question Generator| Generated questions| In-memory +| Question Service| Questions for game| In-memory +| Player Service| Player data| In-memory |=== \ No newline at end of file diff --git a/docs/src/04_solution_strategy.adoc b/docs/src/04_solution_strategy.adoc index e36799dd..097e8fe7 100644 --- a/docs/src/04_solution_strategy.adoc +++ b/docs/src/04_solution_strategy.adoc @@ -14,7 +14,7 @@ The Model-View-Controler architecture pattern will be followed for structuring t === Organizational Decisions Development Methodology: GitFlow will be adopted for project management, facilitating collaboration and iterative delivery. -* The default branch: master. This branch will only be used to create new releases and will contain the most stable version of the system. -* The development branch: develop. This branch will be used to integrate the features developed by the team and will be the basis for creating new releases. This will have the largest commit amount, and will also have a stable version that could be deployed at any time. +* The default branch: https://github.com/Arquisoft/wiq_es04b/tree/master[master]. This branch will only be used to create new releases and will contain the most stable version of the system. +* The development branch: https://github.com/Arquisoft/wiq_es04b/tree/develop[develop]. This branch will be used to integrate the features developed by the team and will be the basis for creating new releases. This will have the largest commit amount, and will also have a stable version that could be deployed at any time. * Feature branches: These branches will be used to develop new features, and will be created from the develop branch. After the feature is developed, it will be merged into the develop branch and branch can be deleted, although we will not usually delete branches. * Fix branches: These branches will be used to fix bugs, and will be created from the develop branch. After the bug is fixed, it will be merged into the develop branch and branch can be deleted, although we will not usually delete branches. \ No newline at end of file diff --git a/docs/src/05_building_block_view.adoc b/docs/src/05_building_block_view.adoc index c8bef175..404f7183 100644 --- a/docs/src/05_building_block_view.adoc +++ b/docs/src/05_building_block_view.adoc @@ -8,7 +8,7 @@ The Building Block View elaborates on the static structure of the system. It dec === Whitebox Overall System -This section provides an overview of the main components of the system and their interactions. The core of the system is the WIQ (QuestionGame) component, which interfaces with Users, Wikidata for question generation, a Database for persistence, and offers a REST API for External Developers. +This section provides an overview of the main components of the system and their interactions. The core of the system is the WIQ (WiqEs04bApplication) component, which interfaces with Users, Wikidata for question generation, a Database for persistence, and offers a REST API for External Developers. [plantuml,"Whitebox-overall",png] ---- @@ -17,11 +17,8 @@ This section provides an overview of the main components of the system and their actor User actor "External Developers" as Dev -component "[WIQ QuestionGame]" as WIQ { - component "[Game Logic]" as Logic - component "[User Account\nManagement]" as UserMgmt - component "[Question\nManagement]" as QuestMgmt - component "[API\nManagement]" as APIMgmt +component "com.uniovi.WiqEs04bApplication" as WIQ { + } database "Database" as DB [WikiData] as WikiData @@ -29,7 +26,7 @@ database "Database" as DB User --> WIQ : Interacts WIQ --> WikiData : Fetches data WIQ --> DB : Reads/Writes Data -Dev --> APIMgmt : Uses APIs +Dev --> WIQ : Uses APIs @enduml ---- @@ -47,7 +44,7 @@ The decomposition provides a clear, high-level overview of how the WIQ system in | User | Represents the end users of the WIQ application, interacting with the system to play games and view their history. -| WIQ (QuestionGame) +| WIQ (WiqEs04bApplication) | The central component that manages gameplay logic, user interactions, and integrates external data for question generation. | Wikidata @@ -64,13 +61,13 @@ The decomposition provides a clear, high-level overview of how the WIQ system in For Level 2 of the Building Block View, the WIQ (QuestionGame) system is further decomposed into four primary components that define its operational structure. Each component is designed to fulfill specific roles within the architecture, ensuring the system's functionality and responsiveness to user interactions. -* *Game Logic:* This component acts as the core of the QuestionGame, orchestrating the flow of games, managing question selection, enforcing game rules, and tracking scores. It ensures that gameplay proceeds smoothly and according to the predefined logic, offering a seamless experience for the user. +* *GameService:* This component acts as the core of the QuestionGame, orchestrating the flow of games, managing question selection, enforcing game rules, and tracking scores. It ensures that gameplay proceeds smoothly and according to the predefined logic, offering a seamless experience for the user. -* *User Account Management:* Responsible for handling user accounts, this component manages registration, authentication, and profile management. It safeguards user data while providing a personalized experience through game history tracking and preference settings. +* *PlayerService:* Responsible for handling user accounts, this component manages registration, authentication, and profile management. It safeguards user data while providing a personalized experience through game history tracking and preference settings. -* *Question Management:* This component interacts directly with the Wikidata service to fetch data for question generation. It processes and curates content to produce relevant, challenging questions for the game, thereby ensuring a varied and educational experience. +* *QuestionService:* This component interacts directly with the Wikidata service to fetch data for question generation. It processes and curates content to produce relevant, challenging questions for the game, thereby ensuring a varied and educational experience. -* *API Management:* Serving as the gateway for external developers, this component exposes a set of RESTful APIs that allow access to player information and question data. It handles request processing, authentication, and data delivery, facilitating third-party integrations and extensions of the WIQ platform. +* *RestApiService:* Serving as the gateway for external developers, this component exposes a set of RESTful APIs that allow access to player information and question data. It handles request processing, authentication, and data delivery, facilitating third-party integrations and extensions of the WIQ platform. [plantuml,"level2",png] @@ -78,23 +75,29 @@ For Level 2 of the Building Block View, the WIQ (QuestionGame) system is further @startuml !theme plain -package "WIQ QuestionGame" { - component "[Game Logic]" as Logic - component "[User Account\nManagement]" as UserMgmt - component "[Question\nManagement]" as QuestMgmt - component "[API\nManagement]" as APIMgmt - - UserMgmt -[hidden]-> Logic : <> - QuestMgmt -[hidden]-> Logic : <> - APIMgmt -[hidden]-> UserMgmt : <> - APIMgmt -[hidden]-> QuestMgmt : <> - - Logic ..> UserMgmt : Uses - Logic ..> QuestMgmt : Uses - UserMgmt ..> APIMgmt : Interfaces - QuestMgmt ..> APIMgmt : Interfaces +actor User +actor "External Developers" as Dev +component "com.uniovi.WiqEs04bApplication" { + package "com.uniovi.services" { + component "[GameSession\nService]" as Logic + component "[PlayerService]" as UserMgmt + component "[QuestionService]" as QuestMgmt + component "[RestApi\nService]" as APIMgmt + + UserMgmt -[hidden]-> Logic : <> + QuestMgmt -[hidden]-> Logic : <> + APIMgmt -[hidden]-> UserMgmt : <> + APIMgmt -[hidden]-> QuestMgmt : <> + + Logic ..> UserMgmt : Uses + Logic ..> QuestMgmt : Uses + UserMgmt ..> APIMgmt : Interfaces + QuestMgmt ..> APIMgmt : Interfaces + } } +User --> [com.uniovi.WiqEs04bApplication] : Interacts +Dev --> APIMgmt : Uses APIs @enduml ---- @@ -113,7 +116,7 @@ This level of documentation provides a structured and clear view of the system's @startuml !theme plain -package "API Management" { +package "com.uniovi.services.RestApiService" { interface "Player Information API" as PlayerAPI interface "Question Information API" as QuestAPI diff --git a/docs/src/07_deployment_view.adoc b/docs/src/07_deployment_view.adoc index 08de22f5..d623500f 100644 --- a/docs/src/07_deployment_view.adoc +++ b/docs/src/07_deployment_view.adoc @@ -22,6 +22,8 @@ The primary motivation behind using Docker for deployment is to streamline the d .Mapping of Building Blocks to Infrastructure - **Web Server/Application (.jar file):** Packaged within a Docker container, it includes all necessary dependencies to run independently across any Docker-supported platform. - **External APIs (e.g., Wikidata API):** Accessed over the network, these APIs provide dynamic content for the game. +- **Grafana**: Monitoring tool that can be used to visualize and analyze metrics from the application and infrastructure. +- **Prometheus**: Monitoring tool that collects metrics from the application and infrastructure for Grafana to visualize. === Infrastructure Level 2 @@ -33,6 +35,8 @@ Our app's Docker container is built from a Java base image, which is then layere In addition to the Spring boot standalone file, we also use the official `MySQL` server docker container image brought by DockerHub. This is our database server and it is used to store the game data, such as user scores, questions, etc. and all the other persistent data. +Moreover, there are two more containers. These are the monitoring tools, `Prometheus` and `Grafana`. These tools are used to monitor the application and infrastructure. Prometheus collects metrics from the application and infrastructure, while Grafana visualizes these metrics. + This setup encapsulates the entire runtime environment required for our application, and does not require extensive configuration. .Diagram: Docker Container Setup @@ -48,16 +52,28 @@ rectangle "Docker compose" { rectangle "MySQL Server Container" { database "MySQL Server" as MySQL } + + rectangle "Prometheus Container" { + database "Prometheus" as Prometheus + } + + rectangle "Grafana Container" { + node "Grafana" as Grafana + } } cloud "Wikidata API" as API App --> MySQL : Store game data MySQL --> App : Fetch game data -App --> API : Fetch questions +App -left-> API : Fetch questions App ..> WebServer : Server application +Prometheus --> App : Collect metrics +Grafana --> Prometheus : Visualize metrics @enduml ---- .Explanation: -This diagram illustrates the internal structure of our Docker containers structure. It shows the Java Spring Boot application, including the embedded web server, packaged as a `.jar` file and the MySQL server. The application interacts with external APIs, like the Wikidata API, to retrieve data necessary for generating game questions. The containerized approach ensures that the application can be deployed consistently across any environment that supports Docker. \ No newline at end of file +This diagram illustrates the internal structure of our Docker containers structure. It shows the Java Spring Boot application, including the embedded web server, packaged as a `.jar` file and the MySQL server. The application interacts with external APIs, like the Wikidata API, to retrieve data necessary for generating game questions. +To ensure the application's health and performance, we use Prometheus to collect metrics and Grafana to visualize these metrics. +The containerized approach ensures that the application can be deployed consistently across any environment that supports Docker. \ No newline at end of file diff --git a/docs/src/08_concepts.adoc b/docs/src/08_concepts.adoc index 65efb28b..e3d3e233 100644 --- a/docs/src/08_concepts.adoc +++ b/docs/src/08_concepts.adoc @@ -7,8 +7,9 @@ The following concepts provide a foundation for the design and implementation of === Domain Model -The domain model for our game includes entities such as `Question`, `Category`, `Player`, `Role`, and `GameSession`. These are crucial for representing the game's data and logic. The model serves as the basis for interactions within the application and between the application and the database. +The domain model for our game includes entities such as https://github.com/Arquisoft/wiq_es04b/blob/master/src/main/java/com/uniovi/entities/Question.java[Question], https://github.com/Arquisoft/wiq_es04b/blob/master/src/main/java/com/uniovi/entities/Category.java[Category], https://github.com/Arquisoft/wiq_es04b/blob/master/src/main/java/com/uniovi/entities/Player.java[Player], https://github.com/Arquisoft/wiq_es04b/blob/master/src/main/java/com/uniovi/entities/Role.java[Role], or https://github.com/Arquisoft/wiq_es04b/blob/master/src/main/java/com/uniovi/entities/GameSession.java[GameSession]. These are crucial for representing the game's data and logic. The model serves as the basis for interactions within the application and between the application and the database. +https://github.com/Arquisoft/wiq_es04b/tree/master/src/main/java/com/uniovi/entities[Source code] [plantuml, domain-model, svg, subs="attributes", subs="methods"] ---- @@ -19,15 +20,18 @@ class Question { - options: List - correctAnswer: Answer - category: Category + - language: String + addOption(option: Answer): void + removeOption(option: Answer): void + getOption(index: int): Answer + + getOptions(answer: String): Answer + isCorrectAnswer(answer: Answer): boolean + scrambleOptions(): void + equals(o: Object): boolean + hashCode(): int + toString(): String + toJson(): JsonNode + + hasEmptyOptions(): boolean } class Category { @@ -45,6 +49,8 @@ class Player { - email: String - password: String - passwordConfirm : String + - multiplayerCode : Integer + - scoreMultiplayerCode : String - roles: Set - gameSessions: Set - apiKey: ApiKey @@ -71,9 +77,17 @@ class GameSession { + getDuration(): String } +class MultiplayerSession { + - id: Long + - multiplayerCode: String + - playerScores: Map + + addPlayer(player: Player): void +} + class Role { - name: String - players: Set + + toString(): String } class Answer { @@ -106,6 +120,7 @@ ApiKey "1" --* "1" Player ApiKey "1" *-- "*" RestApiAccessLog Category "1" -- "*" Question Player "1" *-- "*" GameSession +Player "1" *-- "*" MultiplayerSession GameSession "1" -- "*" Question : Answered Questions GameSession "1" -- "*" Question : Questions To Answer @@ -157,5 +172,7 @@ Our CI/CD pipeline automates the process of integrating code changes, building t === Scalability Designing for scalability, the application can accommodate an increasing number of users and interactions without performance degradation. + .Explanation: -Scalable solutions such as Docker containers allow the application to be deployed in a distributed environment, where resources can be adjusted based on demand. \ No newline at end of file +Scalable solutions such as Docker containers allow the application to be deployed in a distributed environment, where resources can be adjusted based on demand. +Given that we use a monolithic approach, the application can be easily scaled horizontally by deploying multiple instances of the same service and connect them to a load balancer in order to distribute the load. \ No newline at end of file diff --git a/docs/src/09_architecture_decisions.adoc b/docs/src/09_architecture_decisions.adoc index fcfeb49c..b42a94c5 100644 --- a/docs/src/09_architecture_decisions.adoc +++ b/docs/src/09_architecture_decisions.adoc @@ -12,5 +12,5 @@ The purpose of this section is to create an ordered list of architectural decisi * https://github.com/Arquisoft/wiq_es04b/wiki/Record-of-architectural-decisions#dto-pattern[ADR 05] - DTO Pattern * https://github.com/Arquisoft/wiq_es04b/wiki/Record-of-architectural-decisions#mysql-in-production-and-hsqldb-in-local[ADR 06] - MySQL in production and HSQLDB in local * https://github.com/Arquisoft/wiq_es04b/wiki/Record-of-architectural-decisions#monolithic-architecture[ADR 07] - Monolithic architecture -* https://github.com/Arquisoft/wiq_es04b/wiki/Record-of-architectural-decisions#use-of-tbd[ADR 08] - Use of TBD +* https://github.com/Arquisoft/wiq_es04b/wiki/Record-of-architectural-decisions#use-of-gitflow[ADR 08] - Use of GitFlow * https://github.com/Arquisoft/wiq_es04b/wiki/Record-of-architectural-decisions#questions-refreshing[ADR 09] - Questions refreshing \ No newline at end of file diff --git a/docs/src/10_quality_requirements.adoc b/docs/src/10_quality_requirements.adoc index 175b66ff..f9547253 100644 --- a/docs/src/10_quality_requirements.adoc +++ b/docs/src/10_quality_requirements.adoc @@ -3,51 +3,44 @@ ifndef::imagesdir[:imagesdir: ../images] [[section-quality-scenarios]] == Quality Requirements === Quality Tree -_Note: Items (1), (2), (3) in the table below repeat the higher-level quality requirements of Chapter 1.2._ -[cols="4", options="header"] +[cols="3", options="header"] |=== -|Quality category |Quality |Description |Scenario +|Quality requirement |Description |Scenario -|Usability -|Ease of use -|The application shall be easy to use by the user, with intuitive functionality. +|Efficiency +|Access, creation of questions, and navigation between them should be fast to ensure user satisfaction. |SC1 -| -|Familiarity of the environment (2) -|The application should appeal to all fans of the original programme and provide a wide variety of questions. -| +|Usability +|The application should be appealing to all fans of the original program while also offering a wide variety of questions. +|SC2 + +|Manteinance +|The application should ensure easy expansion and modification to provide users with new features. +|SC3 -|Performance -|Accuracy -|The questions in the application must be accurate, both the question and the correct answer. -| +|Availability +|Our goal is to achieve at least 95% availability, ensuring that the system is always available for users to play. +|SC4 -| -|Efficiency (1) -|Accessing, creating questions and moving between questions should be fast to ensure user satisfaction. -| +|Responsiveness +|The system must be responsive, providing a good user experience for those in desktop and mobile environments. +|SC5 -| -|Robustness -|The system shall function reliably in all specified environments and operating conditions. -|SC2 +|Testability +|The system must facilitate the testing process(creating test cases, executing tests, analyzing results...) +|SC6 -|Safety -|Integrity -|The application shall be user-friendly, with intuitive functionality. -| +|Scalability +|The ability of a system to handle increasing workload or growing demands by adapting or expanding its capacity without compromising performance. +|SC7 + +|Interoperability +|The system must have the ability of seamlessly communicate, exchange data, and work together effectively with different systems, applications, or components, even if they use different technologies. +|SC8 -|Maintainability and support -|Maintenance (2) -|The application shall ensure that it can be easily extended and modified to provide new features to users. -| -|Cultural and Regional -|Multilingual -|The user interface texts must be able to be converted by a translation file into different languages with an ASCII character set. -|SC3 |=== === Quality Scenarios @@ -55,12 +48,28 @@ _Note: Items (1), (2), (3) in the table below repeat the higher-level quality re |=== |Identification |Scenario -|SC1 -|A user who is not familiar with the application will know how to use it after a few minutes of instruction. +| SC1 +| Users expect the application to load quickly and respond promptly to their interactions, ensuring a smooth and efficient user experience. -|SC2 -|The application should be able to be run from any device, from a computer to a mobile phone, without losing formatting. +| SC2 +| The application's user interface is designed intuitively, allowing users to navigate effortlessly and find what they need without confusion or frustration. + +| SC3 +| Developers can easily add new features or modify existing ones without disrupting the overall functionality of the application, ensuring its long-term maintainability. + +| SC4 +| Users rely on the application to be available whenever they want to play, and the system ensures a high level of uptime, minimizing downtime for maintenance or updates. + +| SC5 +| Whether accessed from a desktop computer or a mobile device, users expect the application to adapt seamlessly to their screen size and input method, providing a consistent experience across platforms. + +| SC6 +| The testing process is streamlined, allowing QA teams to efficiently create, execute, and analyze test cases, ensuring the reliability and stability of the application. + +| SC7 +| As user demand grows, the deployment does not grow dynamically. However a system can be put in place to deploy more instances of the application to handle the increased workload, trough a load balancer, for example. + +| SC8 +| The application seamlessly integrates with other systems, allowing for the exchange of data and functionality without compatibility issues, enhancing its overall interoperability. -|CS3 -|With the appropriate translation files replacing the default language (English), all displayed and printed texts now appear in this language. |=== \ No newline at end of file diff --git a/docs/src/11_technical_risks.adoc b/docs/src/11_technical_risks.adoc index 32043b2f..63c22015 100644 --- a/docs/src/11_technical_risks.adoc +++ b/docs/src/11_technical_risks.adoc @@ -6,10 +6,11 @@ ifndef::imagesdir[:imagesdir: ../images] |=== | Priority | Risk | Description -| High | Migration to Java | Migration from the current project language, JavaScript (JS), to Java +| High | Architecture in Spring Boot | The architecture of the project will be based on Spring Boot, which is a framework that we are not that familiar with, or not at all for some members. This could lead to a delay in the development of the project. +| Medium | Wikidata | The project will use Wikidata as a source of data. We have never used Wikidata before, so we will have to learn how to use it. | Medium | IDE Configuration | Version compatibility, extensions and other preferences to work perfectly without conflicts -| Medium | Database | Discuss which database is best for the project -| Low | Docker | Know how docker works, what it is for, how it is used and what its alternatives could be. +| Medium | Docker | Know how docker works, what it is for, how it is used and what its alternatives could be. +| Low | Database | The database decision will not be taken based on what technology we know better, but on what is best for the project. Usage of JPA, Hibernate allows us to focus on the business logic and not on the database. |=== @@ -17,6 +18,9 @@ ifndef::imagesdir[:imagesdir: ../images] [cols="1,2,3a", options="header"] |=== | Priority | Debt | Description -| Low | Microservices | Research about microservices and what they can contribute to the project +| Medium | Documentation | The documentation of the project will be done in parallel with the development of the project. This could lead to a delay in the development of the project. +| Medium | Testing | The project will have unit and integration tests, we ensure tests cover the majority of the code. However due to time constraints and how the project is structured, we may not be able to cover all the code with all the possible cases. The aim is to cover at least 80% of the code. +| Low | Code Quality | The code quality will be ensured by the use of SonarCloud, but due to time constraints, we may not be able to fix all the issues that SonarCloud will find. +| Low | Security | The project will have security measures in place. Standard practices are put in place. Tougher security measures will not be implemented due to performance constraints. |=== diff --git a/docs/src/12_glossary.adoc b/docs/src/12_glossary.adoc index 5ba9eab8..d5020dfb 100644 --- a/docs/src/12_glossary.adoc +++ b/docs/src/12_glossary.adoc @@ -24,4 +24,6 @@ ifndef::imagesdir[:imagesdir: ../images] |Associations | Represents the associations between entities. It has internal classes for each association. +|MultiplayerSession | Represents a multiplayer session, which is a game session with more than one player. + |=== diff --git a/docs/src/13_question_generation_strategy.adoc b/docs/src/13_question_generation_strategy.adoc new file mode 100644 index 00000000..da51e78a --- /dev/null +++ b/docs/src/13_question_generation_strategy.adoc @@ -0,0 +1,137 @@ +ifndef::imagesdir[:imagesdir: ../images] + +[[section-question-generation-strategy]] +== Question Generation Strategy + +The generation of questions in our application is handled through a combination of classes and interfaces, primarily QuestionGeneratorV2 and QuestionGeneratorService. + +=== Question templates + +The two classes mentioned before use a JSON file that stores the question templates. These templates are used to generalize the question generation process. The JSON file is structured as follows: + +==== JSON Structure + +The JSON has the following structure: + +- `language_placeholder`, `question_placeholder`, and `answer_placeholder` are strings used as placeholders for the language, question, and answer, respectively. + +- `categories` is an array containing objects representing different categories of questions. + +Each category object has two properties: + +- `name`: The name of the category. +- `questions`: An array of objects representing questions within that category. + +Each question object has the following properties: + +- `type`: The type of question. +- `statements`: An array of objects representing statements in different languages for the question. Each object has two properties: `language` and `statement`. +- `question`: The property queried to generate the question. +- `answer`: The property queried to obtain the answer. +- `sparqlQuery`: The SPARQL query used to retrieve data for generating the question and answer. + +==== Compact Example of JSON Hierarchy + +[source,json] +---- +{ + "language_placeholder": "[LANGUAGE]", + "question_placeholder": "[QUESTION]", + "answer_placeholder": "[ANSWER]", + "categories": [ + { + "name": "Geography", + "questions": [ + { + "type": "capital", + "statements": [ + { + "language": "es", + "statement": "ÂżCuĂĄl es la capital de [QUESTION]?" + }, + { + "language": "en", + "statement": "What is the capital of [QUESTION]?" + }, + { + "language": "fr", + "statement": "Quelle est la capitale de [QUESTION]?" + } + ], + "question": "countryLabel", + "answer": "capitalLabel", + "sparqlQuery": "..." + }, + { + "type": "currency", + "statements": [ + { + "language": "es", + "statement": "ÂżCuĂĄl es la moneda de [QUESTION]?" + }, + { + "language": "en", + "statement": "What is the currency of [QUESTION]?" + }, + { + "language": "fr", + "statement": "Quelle est la monnaie de [QUESTION]?" + } + ], + "question": "countryLabel", + "answer": "currencyLabel", + "sparqlQuery": "..." + } + ] + }, + { + "name": "Science", + "questions": [ + { + "type": "element", + "statements": [ + { + "language": "es", + "statement": "ÂżCuĂĄl es el sĂ­mbolo quĂ­mico del [QUESTION]?" + }, + { + "language": "en", + "statement": "What is the chemical symbol of [QUESTION]?" + }, + { + "language": "fr", + "statement": "Quel est le symbole chimique du [QUESTION]?" + } + ], + "question": "elementLabel", + "answer": "symbol", + "sparqlQuery": "..." + } + ] + } + ] +} +---- + +=== Example of a query +Here's an example of how you could use this JSON structure to query information: + +To execute a query you just need to build a SPARQL query that retrieves the data you need. For example, to get the capital of a country, you could use the following query: +[source, SPARQL] +---- +select distinct ?country ?countryLabel ?capital ?capitalLabel where { + ?country wdt:P31 wd:Q6256 . + ?capital wdt:P31 wd:Q5119 . + ?country wdt:P36 ?capital . + ?country rdfs:label ?countryLabel . + ?capital rdfs:label ?capitalLabel . + FILTER NOT EXISTS {?country wdt:P31 wd:Q3024240} . + FILTER(LANG(?countryLabel)="es" && LANG(?capitalLabel)="es") + } +---- +Once you have a query the next step is to add it to thr JSON file. Following the previous example, you should start declaring a new object inside the `questions` array of the `Geography` category. You should fill the `type`, `statements`, `question`, `answer`, and `sparqlQuery` properties with the appropriate values. For the `type` field you could add the value `"capital"`, for the `statements` array you could add the statements in different languages also adding to them the placeholder that you used in the `question_placeholder` field. For the `question` field you should add the value `"countryLabel"`, for the `answer` you should add the value `"capitalLabel"`. For the `sparqlQuery` field you should replace in your original query every instance of `"countryLabel"` with the value of `question_placeholder` and `"capitalLabel"` with the value of `answer_placeholder`. The final object should look like the one showed in the JSON hierarchy example. + +By following this approach, you can dynamically generate a wide variety of questions based on the structured data stored in your JSON file. This allows for flexibility and scalability in your question generation process. + +=== Question Generation Process +Once we have the JSON file with the question templates, we can start generating questions. Each time the application starts the QuestionGeneratorService class reads the JSON file and stores the question templates in memory. This way, the application can generate questions without needing to read the file each time. These templates are stored in a synchronized stack, which allows for thread-safe access to the templates. When the application starts after reading the JSON file and storing the templates the QuestionGeneratorService class deletes all the questions that are stored in the database. After deleting the questions, the QuestionGeneratorService starts a cycle that generates all the questions in all the 3 main languages of the app for a specific type of question each two and a half minutes. This cycle is repeated until all the question types are generated. After this every 24 hours the cycle will start again with the parsing of the JSON, filling the Stack, emptying the database and generating the questions. All this process can be forced by an Admin user through the Admin panel in the app where an Admin can change the JSON (which will lead to the restart of the question generation process) or delete all questions. \ No newline at end of file diff --git a/docs/src/14_testing.adoc b/docs/src/14_testing.adoc new file mode 100644 index 00000000..55b7f0ef --- /dev/null +++ b/docs/src/14_testing.adoc @@ -0,0 +1,155 @@ +ifndef::imagesdir[:imagesdir: ../images] + +[[section-testing]] +== Testing +To be able to achieve continuous delivery, we need to have a good test coverage. We need to have a good test coverage to be able to refactor the code without breaking it, to be able to deliver the software with confidence and to be able to deliver the software with quality. + +=== Unit Test +In our project, we have implemented unit tests for various components of the application. The tests are written in Java and use the JUnit 5 framework for testing. We also use the Spring Boot Test framework for testing Spring Boot applications, which provides utilities and annotations to test the application in a way that is very close to its actual runtime behavior. The tests are located in the src/test/java/com/uniovi directory. The main test class is Wiq_UnitTests.java, which contains tests for various services and repositories in our application. + +=== Integration Test (E2E) +For the integration tests, we have implemented tests using the Cucumber framework. Cucumber is a tool that supports Behavior-Driven Development (BDD), which allows you to write tests in a human-readable format. The tests are written in Gherkin, a language that is easy to understand and write. To implement each one of the cucumber steps we have used the Selenium framework, which allows us to interact with the web browser and automate the tests mocking the user's behavior. All the cucumber files are located in the src/test/resources/features directory. We've implemented all the cucumber steps in the src/test/java/com/uniovi/steps directory. The main test class is CucumberRunnerTests.java in src/test/java/com/uniovi, which you should run to execute the integration tests. + +=== Load Test +We have implemented load tests to evaluate the performance of our website under extreme situations. For this we use the "Gatling" application, which allows us to evaluate a set of web requests by simulating the number of users who make them simultaneously. +All tests have been carried out with a load of 1, 250, 1000, 2000 and 5000 users, for the following use cases: login, show profile, show apikey, play a game, show personal ranking, show the global ranking, change the language, return to the index and log out. + +==== Test results + +===== Login +1 User: +image:Login/1UserLogin.png[Login1User] +250 Users: +image:Login/250UsersLogin.png[Login250Users] +1000 Users: +image:Login/1000UsersLogin.png[Login1000Users] +2000 Users: +image:Login/2000UsersLogin.png[Login2000Users] +5000 Users: +image:Login/5000UsersLogin.png[Login5000Users] +General information: All responses to the server have generally worked correctly, except for the third response, which logs in using existing credentials. We don't know exactly why this last request doesn't work. If we analyze how the number of users affects the login, at first it does not generate any errors, but response times increase considerably, until reaching 5000 active users at the same time, then there is some error related to a timeout. +image:Login/GeneralLogin.png[GeneralLogin] +image:Login/GraphicLogin.png[GraphicLogin] + +===== Show ApiKey +1 User: +image:ShowApiKey/1UserApiKey.png[ApiKey1User] +250 Users: +image:ShowApiKey/250UsersApiKey.png[ApiKey250Users] +1000 Users: +image:ShowApiKey/1000UsersApiKey.png[ApiKey1000Users] +2000 Users: +image:ShowApiKey/2000UsersApiKey.png[ApiKey2000Users] +5000 Users: +image:ShowApiKey/5000UsersApiKey.png[ApiKey5000Users] +General information: For this use case, from the beginning all requests worked correctly. As the active users increased, the response time increased, but it was not until reaching 5,000 active users that the request failed, logically related to a timeout. In any case, the graph of the number of responses per second shows quite balanced, with only a large gap of erroneous requests. +image:ShowApiKey/GeneralApiKey.png[GeneralApiKey] +image:ShowApiKey/GraphicApiKey.png[GraphicGraphic] + +===== Show profile +1 User: +image:ShowProfile/1UserProfile.png[Profile1User] +250 Users: +image:ShowProfile/250UsersProfile.png[Profile250Users] +1000 Users: +image:ShowProfile/1000UsersProfile.png[Profile1000Users] +2000 Users: +image:ShowProfile/2000UsersProfile.png[Profile2000Users] +5000 Users: +image:ShowProfile/5000UsersProfile.png[Profile5000Users] +General information: The results of the load tests carried out to show the player's profile almost completely coincide with the results of showing the ApiKey, since in both tests two requests are made, which are supported very well by active users, up to 5000 users, where there are already some failed requests but in rare moments. +image:ShowProfile/GeneralProfile.png[GeneralProfile] +image:ShowProfile/GraphicProfile.png[GraphicProfile] + +===== Play a game +1 User: +image:PlayGame/1UserGame.png[Game1User] +250 Users: +image:PlayGame/250UsersGame.png[Game250Users] +1000 Users: +image:PlayGame/1000UsersGame.png[Game1000Users] +2000 Users: +image:PlayGame/2000UsersGame.png[Game2000Users] +5000 Users: +image:PlayGame/5000UsersGame.png[Game5000Users] +General information: The tests of playing the game were the most expensive loading test by far, since each of the users makes a total of 94 requests to the website, in which the vast majority of them are to update visual features of the game such as points, counter or time bar. These tests were so heavy that we had to modify the usability of the application on the host computer, because it ran out of resources when testing 1000 users. Surprisingly, the website supports up to 1000 active users simultaneously without causing any problems other than slightly high waiting times. But after 2,000 active users, failures increase exponentially. The graph of the 5000 users is shown below, where there is a peak in which there are 4193 active users and more than 5000 requests are being made, of which 3000 of them fail. In total, 280,000 requests are made, really heavy. +image:PlayGame/GeneralGame.png[GeneralProfile] +image:PlayGame/GraphicGame.png[GraphicProfile] + +===== Show personal ranking +1 User: +image:PersonalRanking/1UserPR.png[PR1User] +250 Users: +image:PersonalRanking/250UsersPR.png[PR250Users] +1000 Users: +image:PersonalRanking/1000UsersPR.png[PR1000Users] +2000 Users: +image:PersonalRanking/2000UsersPR.png[PR2000Users] +5000 Users: +image:PersonalRanking/5000UsersPR.png[PR5000Users] +General information: For these tests we return a little to normal, compared to the previous tests that were excessively heavy. For these tests, access to the personal ranking and its successive pages was attempted. The results were very positive, with very low response times for up to 5000 users, in the latter case generating some failures that are not proportionally relevant. +image:PersonalRanking/GeneralPR.png[GeneralPR] +image:PersonalRanking/GraphicPR.png[GraphicPR] + +===== Show global ranking +1 User: +image:GlobalRanking/1UserGR.png[GR1User] +250 Users: +image:GlobalRanking/250UsersGR.png[GR250Users] +1000 Users: +image:GlobalRanking/1000UsersGR.png[GR1000Users] +2000 Users: +image:GlobalRanking/2000UsersGR.png[GR2000Users] +5000 Users: +image:GlobalRanking/5000UsersGR.png[GR5000Users] +General information: These tests are very similar to the previous ones, but instead of being the personal ranking it is the global one. However, the response times are very different, being much longer in the case of the global ranking. This is because not only do you have to calculate all the players who have a score, but also add their internal scores to know their position in the ranking, which entails a much greater burden, leading to errors related to timeout when there are 5000 users. Furthermore, the graph shows a rather curious peak of errors, in which 2000 simultaneous requests are made and 1900 are failed. +image:GlobalRanking/GeneralGR.png[GeneralGR] +image:GlobalRanking/GraphicGR.png[GraphicGR] + +===== Change language +1 User: +image:ChangeLanguage/1UserLanguage.png[Language1User] +250 Users: +image:ChangeLanguage/250UsersLanguage.png[Language250Users] +1000 Users: +image:ChangeLanguage/1000UsersLanguage.png[Language1000Users] +2000 Users: +image:ChangeLanguage/2000UsersLanguage.png[Language2000Users] +5000 Users: +image:ChangeLanguage/5000UsersLanguage.png[Language5000Users] +General information: The execution of these tests consisted of changing the language from the current one to English, then to French and finally to Spanish. Since it is an update of all the dialogues on the page, we assumed that it was going to cause more problems than the current ones. The tests showed considerably good response times even for the maximum number of users tested, in the latter case potentially generating some errors. +image:ChangeLanguage/GeneralLanguage.png[GeneralLanguage] +image:ChangeLanguage/GraphicLanguage.png[GraphicLanguage] + +===== Return to the index +1 User: +image:Index/1UserIndex.png[Index1User] +250 Users: +image:Index/250UsersIndex.png[Index250Users] +1000 Users: +image:Index/1000UsersIndex.png[Index1000Users] +2000 Users: +image:Index/2000UsersIndex.png[Index2000Users] +5000 Users: +image:Index/5000UsersIndex.png[Index5000Users] +General information: These results surprised us quite a bit, since we thought it was going to be the lightest action since it was only about loading the root directory of the website. However, the response times generated are much higher than expected, with half of the requests being longer than 1200ms for 1000 users, and for the maximum number of users, 9% of the requests failing. +image:Index/GeneralIndex.png[GeneralIndex] +image:Index/GraphicIndex.png[GraphicIndex] + +===== Log out +1 User: +image:LogOut/1UserLogout.png[Logout1User] +250 Users: +image:LogOut/250UsersLogout.png[Logout250Users] +1000 Users: +image:LogOut/1000UsersLogout.png[Logout1000Users] +2000 Users: +image:LogOut/2000UsersLogout.png[Logout2000Users] +5000 Users: +image:LogOut/5000UsersLogout.png[Logout5000Users] +General information: Finally, tests were carried out to log out your user. These tests gave very positive results since, despite the fact that for many users there are high waiting times, no errors were caused due to a timeout. The only errors that you will see, now with the 5000 active users, have to do with the 'ClosedChannelException' exception, which is a verified exception in Java that occurs when trying to perform input/output operations on a channel that has been closed. +image:LogOut/GeneralLogout.png[GeneralLogout] +image:LogOut/GraphicLogout.png[GraphicLogout] + +==== Conclusion +In conclusion, the tests generally show very positive results. In no test, except for the game, are there errors with up to 2000 users simultaneously, and errors begin to appear after 5000 users. Looking at the results, the lightest cases are showing the profile, showing the personal ranking and logging out. On the other hand, the hardest cases are playing the game (by far) and showing the global ranking. \ No newline at end of file diff --git a/pom.xml b/pom.xml index 7961a9d5..2cda64c5 100644 --- a/pom.xml +++ b/pom.xml @@ -10,13 +10,14 @@ com.uniovi wiq_es04b - 0.0.1-SNAPSHOT + 1.1 wiq_es04b wiq_es04b 17 src/main/java,src/test/resources/features ${project.basedir}/target/jacoco.exec + **/controllers/CustomErrorController.java, **/**/InsertSampleDataService.java @@ -114,6 +115,14 @@ springdoc-openapi-starter-webmvc-ui 2.5.0 + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 00000000..fd1d4831 --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,12 @@ +global: + scrape_interval: 2s + scrape_timeout: 1s + +scrape_configs: + - job_name: 'spring-application' + scrape_interval: 2s + scrape_timeout: 1s + metrics_path: '/actuator/prometheus' + scheme: https + static_configs: + - targets: [ 'wikigame.es:443' ] \ No newline at end of file diff --git a/src/main/java/com/uniovi/WiqEs04bApplication.java b/src/main/java/com/uniovi/WiqEs04bApplication.java index 5bfb46bc..fc0a8ac7 100644 --- a/src/main/java/com/uniovi/WiqEs04bApplication.java +++ b/src/main/java/com/uniovi/WiqEs04bApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class WiqEs04bApplication { public static void main(String[] args) { SpringApplication.run(WiqEs04bApplication.class, args); diff --git a/src/main/java/com/uniovi/components/MultipleQuestionGenerator.java b/src/main/java/com/uniovi/components/MultipleQuestionGenerator.java deleted file mode 100644 index e5001403..00000000 --- a/src/main/java/com/uniovi/components/MultipleQuestionGenerator.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.uniovi.components; - -import com.uniovi.components.generators.QuestionGenerator; -import com.uniovi.entities.Question; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -public class MultipleQuestionGenerator { - private QuestionGenerator[] generators; - - public MultipleQuestionGenerator(QuestionGenerator... generators) { - this.generators = generators; - } - - public List getQuestions() throws InterruptedException, IOException { - List questions = new ArrayList<>(); - for (QuestionGenerator generator : generators) { - questions.addAll(generator.getQuestions()); - } - return questions; - } -} diff --git a/src/main/java/com/uniovi/components/QuestionGeneratorTestController.java b/src/main/java/com/uniovi/components/QuestionGeneratorTestController.java deleted file mode 100644 index 89a28e69..00000000 --- a/src/main/java/com/uniovi/components/QuestionGeneratorTestController.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.uniovi.components; - -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class QuestionGeneratorTestController { - - /*@RequestMapping("/test") - public void test() { - List q = qgen.getQuestions(); - for(Question question : q){ - System.out.println(question); - } - }*/ -} diff --git a/src/main/java/com/uniovi/components/generators/AbstractQuestionGenerator.java b/src/main/java/com/uniovi/components/generators/AbstractQuestionGenerator.java deleted file mode 100644 index 9754c2c5..00000000 --- a/src/main/java/com/uniovi/components/generators/AbstractQuestionGenerator.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.uniovi.components.generators; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.uniovi.entities.Answer; -import com.uniovi.entities.Category; -import com.uniovi.entities.Question; -import com.uniovi.services.CategoryService; - -import java.io.IOException; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.util.ArrayList; -import java.util.List; -import java.util.Random; - -public abstract class AbstractQuestionGenerator implements QuestionGenerator{ - private List questions = new ArrayList<>(); - protected final CategoryService categoryService; - - protected Random random = new SecureRandom(); - - protected String statement; - protected String language; - - protected AbstractQuestionGenerator(CategoryService categoryService) { - this.categoryService = categoryService; - } - - public void questionGenerator(String statement, List options, String correctAnswer, Category category){ - List answers = new ArrayList<>(); - //Generamos las respuestas y las añadimos a la lista - for(String s: options){ - Answer answer = new Answer(s, false); - answers.add(answer); - } - //Generamos la respuesta correcta y la añadimos a la lista - Answer correct = new Answer(correctAnswer, true); - answers.add(correct); - - Question question = new Question(statement, answers, correct, category, language); - question.scrambleOptions(); - questions.add(question); - } - - public List getQuestions() throws InterruptedException, IOException { - HttpClient client = HttpClient.newHttpClient(); - String endpointUrl = "https://query.wikidata.org/sparql?query=" + - URLEncoder.encode(this.getQuery(), StandardCharsets.UTF_8) + - "&format=json"; - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(endpointUrl)) - .header("Accept", "application/json") - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - - // Process the JSON response using Jackson ObjectMapper - ObjectMapper objectMapper = new ObjectMapper(); - JsonNode jsonResponse = objectMapper.readTree(response.body()); - - // Access the data from the JSON response - JsonNode resultsNode = jsonResponse.path("results").path("bindings"); - - for (JsonNode result : resultsNode) { - - List options = this.generateOptions(resultsNode, result); - String correctAnswer = this.generateCorrectAnswer(result); - String questionStatement = this.getQuestionSubject(result); - questionGenerator(questionStatement, options, correctAnswer, this.getCategory()); - - } - return questions; - } - - protected abstract List generateOptions(JsonNode results, JsonNode result); - protected abstract String generateCorrectAnswer(JsonNode result); - - protected abstract String getQuestionSubject(JsonNode result); - -} diff --git a/src/main/java/com/uniovi/components/generators/QuestionGenerator.java b/src/main/java/com/uniovi/components/generators/QuestionGenerator.java index fd9356fa..b91061a4 100644 --- a/src/main/java/com/uniovi/components/generators/QuestionGenerator.java +++ b/src/main/java/com/uniovi/components/generators/QuestionGenerator.java @@ -1,5 +1,6 @@ package com.uniovi.components.generators; +import com.fasterxml.jackson.databind.JsonNode; import com.uniovi.entities.Category; import com.uniovi.entities.Question; import org.springframework.stereotype.Component; @@ -9,11 +10,7 @@ @Component public interface QuestionGenerator { + List getQuestions(String language) throws IOException, InterruptedException; - String getQuery(); - List getQuestions() throws InterruptedException, IOException; - - Category getCategory(); - - + List getQuestions(String language, JsonNode question, Category cat) throws IOException, InterruptedException; } diff --git a/src/main/java/com/uniovi/components/generators/QuestionGeneratorV2.java b/src/main/java/com/uniovi/components/generators/QuestionGeneratorV2.java new file mode 100644 index 00000000..db4afcb4 --- /dev/null +++ b/src/main/java/com/uniovi/components/generators/QuestionGeneratorV2.java @@ -0,0 +1,162 @@ +package com.uniovi.components.generators; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.uniovi.entities.Answer; +import com.uniovi.entities.Category; +import com.uniovi.entities.Question; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +public class QuestionGeneratorV2 implements QuestionGenerator{ + private final JsonNode jsonNode; + private final String languagePlaceholder; + private final String questionPlaceholder; + private final String answerPlaceholder; + private String language; + + private final Random random = new SecureRandom(); + private Logger logger = LoggerFactory.getLogger(QuestionGeneratorV2.class); + + public QuestionGeneratorV2(JsonNode jsonNode) { + this.jsonNode = jsonNode; + this.languagePlaceholder = jsonNode.get("language_placeholder").textValue(); + this.questionPlaceholder = jsonNode.get("question_placeholder").textValue(); + this.answerPlaceholder = jsonNode.get("answer_placeholder").textValue(); + } + + @Override + public List getQuestions(String language) throws IOException, InterruptedException { + this.language = language; + List questions = new ArrayList<>(); + JsonNode categories = jsonNode.findValue("categories"); + for(JsonNode category : categories){ + String categoryName = category.get("name").textValue(); + Category cat = new Category(categoryName); + JsonNode questionsNode = category.findValue("questions"); + for(JsonNode question : questionsNode){ + questions.addAll(this.generateQuestion(question, cat)); + } + } + return questions; + } + + @Override + public List getQuestions(String language, JsonNode question, Category cat) throws IOException, InterruptedException { + this.language = language; + return this.generateQuestion(question, cat); + } + + private List generateQuestion(JsonNode question, Category cat) throws IOException, InterruptedException { + // Get the SPARQL query from the JSON + String query = question.get("sparqlQuery").textValue(); + + // Get the question and answer words from the JSON + String questionLabel = question.get("question").textValue(); + String answerLabel= question.get("answer").textValue(); + + // Replace the placeholders in the query with the actual values + query = query.replace(languagePlaceholder, language). + replace(questionPlaceholder, questionLabel). + replace(answerPlaceholder, answerLabel); + + // Execute the query and get the results + JsonNode results = getQueryResult(query); + List questions = new ArrayList<>(); + + // Prepare the statement base based on the language + String statement = this.prepareStatement(question); + + for (JsonNode result : results) { + // Generate the correct answer + String correctAnswer = result.path(answerLabel).path("value").asText(); + Answer correct = new Answer(correctAnswer, true); + + // Generate the options + List options = this.generateOptions(results, correctAnswer, answerLabel); + options.add(correct); + + if (statement != null) { + // Generate the question statement + String questionStatement = statement.replace(questionPlaceholder, result.path(questionLabel).path("value").asText()); + + // Generate the question + Question q = new Question(questionStatement, options, correct, cat, language); + + // Add the question to the list + questions.add(q); + } + } + return questions; + } + + private List generateOptions(JsonNode results, String correctAnswer, String answerLabel) { + List options = new ArrayList<>(); + List usedOptions = new ArrayList<>(); + int size = results.size(); + int tries = 0; + + while (options.size() < 3 && tries < 10) { + int randomIdx = random.nextInt(size); + String option = results.get(randomIdx).path(answerLabel).path("value").asText(); + if (!option.equals(correctAnswer) && !usedOptions.contains(option) ) { + usedOptions.add(option); + options.add(new Answer(option, false)); + } + tries++; + } + return options; + } + + /** + * Generates a statement based on the language of the question + * @param question The question node + * @return The statement in the language of the question or null if the language is not found + */ + private String prepareStatement(JsonNode question) { + JsonNode statementNode = question.findValue("statements"); + for (JsonNode statement : statementNode) { + if (statement.get("language").textValue().equals(language)) { + return statement.get("statement").textValue(); + } + } + return null; + } + + private JsonNode getQueryResult(String query) throws IOException, InterruptedException { + + logger.info("Query: {}", query); + HttpClient client = HttpClient.newHttpClient(); + JsonNode resultsNode; + String endpointUrl = "https://query.wikidata.org/sparql?query=" + + URLEncoder.encode(query, StandardCharsets.UTF_8) + + "&format=json"; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(endpointUrl)) + .header("Accept", "application/json") + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + // Process the JSON response using Jackson ObjectMapper + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode jsonResponse = objectMapper.readTree(response.body()); + + // Access the data from the JSON response + resultsNode = jsonResponse.path("results").path("bindings"); + return resultsNode; + } +} diff --git a/src/main/java/com/uniovi/components/generators/geography/AbstractGeographyGenerator.java b/src/main/java/com/uniovi/components/generators/geography/AbstractGeographyGenerator.java deleted file mode 100644 index d01919e9..00000000 --- a/src/main/java/com/uniovi/components/generators/geography/AbstractGeographyGenerator.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.uniovi.components.generators.geography; - -import com.uniovi.components.generators.AbstractQuestionGenerator; -import com.uniovi.entities.Category; -import com.uniovi.services.CategoryService; - -public abstract class AbstractGeographyGenerator extends AbstractQuestionGenerator { - - protected AbstractGeographyGenerator(CategoryService categoryService) { - super(categoryService); - } - - @Override - public Category getCategory() { - return categoryService.getCategoryByName("Geography"); - } -} diff --git a/src/main/java/com/uniovi/components/generators/geography/BorderQuestionGenerator.java b/src/main/java/com/uniovi/components/generators/geography/BorderQuestionGenerator.java deleted file mode 100644 index 539b1c13..00000000 --- a/src/main/java/com/uniovi/components/generators/geography/BorderQuestionGenerator.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.uniovi.components.generators.geography; - -import com.fasterxml.jackson.databind.JsonNode; -import com.uniovi.services.CategoryService; - -import java.util.*; - -public class BorderQuestionGenerator extends AbstractGeographyGenerator{ - private static Map STATEMENTS = null; - private Set usedCountries = new HashSet<>(); - - public BorderQuestionGenerator(CategoryService categoryService, String language) { - super(categoryService); - if (STATEMENTS == null) { - STATEMENTS = new HashMap<>(); - STATEMENTS.put("en", "Which countries share a border with "); - STATEMENTS.put("es", "ÂżCon quĂ© paĂ­ses comparte frontera "); - STATEMENTS.put("fr", "Avec quels pays partage-t-il une frontiĂšre "); - } - - this.statement = STATEMENTS.get(language); - this.language = language; - } - - private List getAllBorderingCountries(JsonNode resultsNode, String correctCountry) { - List allBorderingCountries = new ArrayList<>(); - for (JsonNode result : resultsNode) { - String borderingCountry = result.path("borderingCountryLabel").path("value").asText(); - if (!borderingCountry.equals(correctCountry)) { - allBorderingCountries.add(borderingCountry); - } - } - return allBorderingCountries; - } - - private List selectRandomIncorrectBorderingCountries(List allBorderingCountries, String correctCountry, int count) { - List incorrectBorderingCountries = new ArrayList<>(); - while (incorrectBorderingCountries.size() < count && allBorderingCountries.size() > 0) { - int randomIndex = random.nextInt(allBorderingCountries.size()); - String selectedBorderingCountry = allBorderingCountries.remove(randomIndex); - if (!selectedBorderingCountry.equals(correctCountry) && !incorrectBorderingCountries.contains(selectedBorderingCountry)) { - incorrectBorderingCountries.add(selectedBorderingCountry); - } - } - return incorrectBorderingCountries; - } - - @Override - protected List generateOptions(JsonNode results, JsonNode result) { - String borderingCountryLabel = result.path("borderingCountryLabel").path("value").asText(); - return selectRandomIncorrectBorderingCountries( - getAllBorderingCountries(results, borderingCountryLabel), - borderingCountryLabel, 3); - } - - @Override - protected String generateCorrectAnswer(JsonNode result) { - return result.path("borderingCountryLabel").path("value").asText(); - } - - @Override - protected String getQuestionSubject(JsonNode result) { - return this.statement + result.path("countryLabel").path("value").asText() + "?"; - } - - @Override - public String getQuery() { - return "SELECT DISTINCT ?country ?countryLabel ?borderingCountry ?borderingCountryLabel\n" + - "WHERE {" + - " ?country wdt:P31 wd:Q3624078 ." + - " FILTER NOT EXISTS {?country wdt:P31 wd:Q3024240}" + - " FILTER NOT EXISTS {?country wdt:P31 wd:Q28171280}" + - " ?country wdt:P47 ?borderingCountry ." + - " SERVICE wikibase:label { bd:serviceParam wikibase:language \"[AUTO_LANGUAGE]," + language + "\" }" + - "}"; - } -} diff --git a/src/main/java/com/uniovi/components/generators/geography/CapitalQuestionGenerator.java b/src/main/java/com/uniovi/components/generators/geography/CapitalQuestionGenerator.java deleted file mode 100644 index 924ef3cf..00000000 --- a/src/main/java/com/uniovi/components/generators/geography/CapitalQuestionGenerator.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.uniovi.components.generators.geography; - -import com.fasterxml.jackson.databind.JsonNode; -import com.uniovi.services.CategoryService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -import java.util.*; - -public class CapitalQuestionGenerator extends AbstractGeographyGenerator{ - private static Map STATEMENTS = null; - - public CapitalQuestionGenerator(CategoryService categoryService, String language) { - super(categoryService); - if (STATEMENTS == null) { - STATEMENTS = new HashMap<>(); - STATEMENTS.put("en", "What is the capital of "); - STATEMENTS.put("es", "ÂżCuĂĄl es la capital de "); - STATEMENTS.put("fr", "Quelle est la capitale de "); - } - - this.statement = STATEMENTS.get(language); - this.language = language; - } - - @Override - public String getQuery() { - return "SELECT DISTINCT ?country ?countryLabel ?capital ?capitalLabel\n" + - "WHERE {" + - " ?country wdt:P31 wd:Q3624078 ." + - " FILTER NOT EXISTS {?country wdt:P31 wd:Q3024240}" + - " FILTER NOT EXISTS {?country wdt:P31 wd:Q28171280}" + - " OPTIONAL { ?country wdt:P36 ?capital } ." + - " SERVICE wikibase:label { bd:serviceParam wikibase:language \"[AUTO_LANGUAGE]," + language + "\" }" + - "}" + - "ORDER BY ?countryLabel"; - } - - private List getAllCapitals(JsonNode resultsNode, String correctCapital) { - // Obtener todas las capitales del JSON (distintas a la capital correcta) - List allCapitals = new ArrayList<>(); - for (JsonNode result : resultsNode) { - String capital = result.path("capitalLabel").path("value").asText(); - if (!capital.equals(correctCapital)) { - allCapitals.add(capital); - } - } - return allCapitals; - } - - private List selectRandomIncorrectCapitals(List allCapitals, String correctCapital, int count) { - List incorrectCapitals = new ArrayList<>(); - while (incorrectCapitals.size() < count && allCapitals.size() > 0) { - int randomIndex = random.nextInt(allCapitals.size()); - String selectedCapital = allCapitals.remove(randomIndex); - if (!selectedCapital.equals(correctCapital) && !incorrectCapitals.contains(selectedCapital)) { - incorrectCapitals.add(selectedCapital); - } - } - return incorrectCapitals; - } - - @Override - protected List generateOptions(JsonNode results, JsonNode result) { - String capitalLabel = result.path("capitalLabel").path("value").asText(); - return selectRandomIncorrectCapitals(getAllCapitals(results, capitalLabel), capitalLabel, 3); - } - - @Override - protected String generateCorrectAnswer(JsonNode result) { - return result.path("capitalLabel").path("value").asText(); - } - - @Override - protected String getQuestionSubject(JsonNode result) { - return this.statement + result.path("countryLabel").path("value").asText() + "?"; - } -} diff --git a/src/main/java/com/uniovi/components/generators/geography/ContinentQuestionGeneration.java b/src/main/java/com/uniovi/components/generators/geography/ContinentQuestionGeneration.java deleted file mode 100644 index df48ec41..00000000 --- a/src/main/java/com/uniovi/components/generators/geography/ContinentQuestionGeneration.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.uniovi.components.generators.geography; - -import com.fasterxml.jackson.databind.JsonNode; -import com.uniovi.services.CategoryService; -import org.springframework.scheduling.annotation.Scheduled; - -import java.util.*; - -public class ContinentQuestionGeneration extends AbstractGeographyGenerator{ - private static Map STATEMENTS = null; - - public ContinentQuestionGeneration(CategoryService categoryService, String language) { - super(categoryService); - - if (STATEMENTS == null) { - STATEMENTS = new HashMap<>(); - STATEMENTS.put("en", "In which continent is "); - STATEMENTS.put("es", "ÂżEn quĂ© continente se encuentra "); - STATEMENTS.put("fr", "Sur quel continent est-il situĂ© "); - } - - this.statement = STATEMENTS.get(language); - this.language = language; - } - - private List getAllContinents(JsonNode resultsNode, String correctContinent) { - // Obtener todas las capitales del JSON (distintas a la capital correcta) - List allContinents = new ArrayList<>(); - for (JsonNode result : resultsNode) { - String continent = result.path("continentLabel").path("value").asText(); - if (!continent.equals(correctContinent)) { - allContinents.add(continent); - } - } - return allContinents; - } - - private List selectRandomIncorrectContinents(List allContinents, String correctContinent, int count) { - List incorrectContinents = new ArrayList<>(); - while (incorrectContinents.size() < count && allContinents.size() > 0) { - int randomIndex = random.nextInt(allContinents.size()); - String selectedCapital = allContinents.remove(randomIndex); - if (!selectedCapital.equals(correctContinent) && !incorrectContinents.contains(selectedCapital)) { - incorrectContinents.add(selectedCapital); - } - } - return incorrectContinents; - } - - @Override - protected List generateOptions(JsonNode results, JsonNode result) { - String continentLabel = result.path("continentLabel").path("value").asText(); - return selectRandomIncorrectContinents(getAllContinents(results, continentLabel), continentLabel, 3); - } - - @Override - protected String generateCorrectAnswer(JsonNode result) { - return result.path("continentLabel").path("value").asText(); - } - - @Override - protected String getQuestionSubject(JsonNode result) { - return this.statement + result.path("countryLabel").path("value").asText() + "?"; - } - - @Override - public String getQuery() { - return "SELECT DISTINCT ?country ?countryLabel ?continent ?continentLabel\n" + - "WHERE {" + - " ?country wdt:P31 wd:Q3624078 " + - " FILTER NOT EXISTS {?country wdt:P31 wd:Q3024240}" + - " FILTER NOT EXISTS {?country wdt:P31 wd:Q28171280}" + - " OPTIONAL { ?country wdt:P30 ?continent } ." + - " SERVICE wikibase:label { bd:serviceParam wikibase:language \"[AUTO_LANGUAGE]," + language + "\" }" + - "}" + - "ORDER BY ?countryLabel"; - } -} diff --git a/src/main/java/com/uniovi/configuration/SecurityConfig.java b/src/main/java/com/uniovi/configuration/SecurityConfig.java index c0af5d20..8cfbc8f3 100644 --- a/src/main/java/com/uniovi/configuration/SecurityConfig.java +++ b/src/main/java/com/uniovi/configuration/SecurityConfig.java @@ -40,14 +40,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(csrf -> csrf .ignoringRequestMatchers("/api/**") ) - .authorizeHttpRequests((authorize) -> + .authorizeHttpRequests(authorize -> authorize .requestMatchers("/css/**", "/img/**", "/script/**").permitAll() .requestMatchers("/home/**").authenticated() .requestMatchers("/signup/**").permitAll() .requestMatchers("/api/**").permitAll() .requestMatchers("/game/**").authenticated() + .requestMatchers("/multiplayerGame/**").authenticated() + .requestMatchers("/startMultiplayerGame/**", "/endGameList/**").authenticated() + .requestMatchers("/lobby/**").authenticated() .requestMatchers("/ranking/playerRanking").authenticated() + .requestMatchers("/player/admin/**").hasAuthority("ROLE_ADMIN") .requestMatchers("/**").permitAll() ).formLogin( form -> form diff --git a/src/main/java/com/uniovi/controllers/CustomErrorController.java b/src/main/java/com/uniovi/controllers/CustomErrorController.java index 1c3c2ae8..074291ca 100644 --- a/src/main/java/com/uniovi/controllers/CustomErrorController.java +++ b/src/main/java/com/uniovi/controllers/CustomErrorController.java @@ -15,6 +15,7 @@ @Controller public class CustomErrorController extends BasicErrorController { + private static final String PATH = "error"; @Autowired public CustomErrorController(ErrorAttributes errorAttributes, ServerProperties serverProperties, List errorViewResolvers) { super(errorAttributes, serverProperties.getError(), errorViewResolvers); @@ -23,10 +24,10 @@ public CustomErrorController(ErrorAttributes errorAttributes, ServerProperties s @RequestMapping(value = "/error") public String error(Model model, HttpServletRequest webRequest) { Map errorAttributes = this.getErrorAttributes(webRequest, ErrorAttributeOptions.defaults()); - model.addAttribute("error", errorAttributes.get("error")); + model.addAttribute(PATH, errorAttributes.get(PATH)); model.addAttribute("message", errorAttributes.get("message")); model.addAttribute("status", errorAttributes.get("status")); model.addAttribute("trace", errorAttributes.get("trace")); // Add the stack trace - return "error"; + return PATH; } } diff --git a/src/main/java/com/uniovi/controllers/GameController.java b/src/main/java/com/uniovi/controllers/GameController.java index 59c20083..c4f46423 100644 --- a/src/main/java/com/uniovi/controllers/GameController.java +++ b/src/main/java/com/uniovi/controllers/GameController.java @@ -1,12 +1,16 @@ package com.uniovi.controllers; import com.uniovi.entities.GameSession; +import com.uniovi.entities.MultiplayerSession; import com.uniovi.entities.Player; import com.uniovi.entities.Question; import com.uniovi.services.GameSessionService; +import com.uniovi.services.MultiplayerSessionService; import com.uniovi.services.PlayerService; import com.uniovi.services.QuestionService; import jakarta.servlet.http.HttpSession; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -16,22 +20,25 @@ import java.security.Principal; import java.time.Duration; import java.time.LocalDateTime; -import java.util.Optional; +import java.util.*; @Controller public class GameController { + private static final String GAMESESSION_STR = "gameSession"; private final QuestionService questionService; private final GameSessionService gameSessionService; private final PlayerService playerService; + private final MultiplayerSessionService multiplayerSessionService; + public GameController(QuestionService questionService, GameSessionService gameSessionService, - PlayerService playerService) { + PlayerService playerService, MultiplayerSessionService multiplayerSessionService) { this.questionService = questionService; this.gameSessionService = gameSessionService; this.playerService = playerService; + this.multiplayerSessionService = multiplayerSessionService; } - /** * This method is used to get the game view and to start the game * @param model The model to be used @@ -39,13 +46,83 @@ public GameController(QuestionService questionService, GameSessionService gameSe */ @GetMapping("/game") public String getGame(HttpSession session, Model model, Principal principal) { + GameSession gameSession = (GameSession) session.getAttribute(GAMESESSION_STR); + if (gameSession != null && !gameSession.isFinished() && !gameSession.isMultiplayer()) { + if (checkUpdateGameSession(gameSession, session)) { + return "game/fragments/gameFinished"; + } + } else { + gameSession = gameSessionService.startNewGame(getLoggedInPlayer(principal)); + session.setAttribute(GAMESESSION_STR, gameSession); + playerService.deleteMultiplayerCode(gameSession.getPlayer().getId()); + } + + model.addAttribute("question", gameSession.getCurrentQuestion()); + model.addAttribute("questionDuration", getRemainingTime(gameSession)); + return "game/basicGame"; + } + + @GetMapping("/multiplayerGame") + public String getMultiplayerGame() { + return "game/multiplayerGame"; + } + + @GetMapping("/multiplayerGame/{code}") + public String joinMultiplayerGame(@PathVariable String code, HttpSession session, Principal principal, Model model) { + if (!multiplayerSessionService.existsCode(code)) { + model.addAttribute("errorKey", "multi.code.invalid"); + return "game/multiplayerGame"; + } + + Optional player = playerService.getUserByUsername(principal.getName()); + Player p = player.orElse(null); + if (playerService.changeMultiplayerCode(p.getId(),code)) { + multiplayerSessionService.addToLobby(code,p.getId()); + model.addAttribute("multiplayerGameCode",code); + session.setAttribute("multiplayerCode",code); + return "redirect:/game/lobby"; + } else { + return "redirect:/multiplayerGame"; + } + } + + @GetMapping("/multiplayerGame/createGame") + public String createMultiplayerGame(HttpSession session, Principal principal, Model model) { + Optional player = playerService.getUserByUsername(principal.getName()); + Player p = player.orElse(null); + String code="" + playerService.createMultiplayerGame(p.getId()); + multiplayerSessionService.multiCreate(code,p.getId()); + session.setAttribute("multiplayerCode",code); + return "redirect:/game/lobby"; + } + + @GetMapping("/startMultiplayerGame") + public String startMultiplayerGame(HttpSession session, Model model, Principal principal) { GameSession gameSession = (GameSession) session.getAttribute("gameSession"); + if (gameSession != null) { + if (! gameSession.isMultiplayer()) { + session.removeAttribute("gameSession"); + return "redirect:/startMultiplayerGame"; + } + + if (gameSession.isFinished()) { + model.addAttribute("code", session.getAttribute("multiplayerCode")); + return "game/multiplayerFinished"; + } + if (checkUpdateGameSession(gameSession, session)) { return "game/fragments/gameFinished"; } } else { - gameSession = gameSessionService.startNewGame(getLoggedInPlayer(principal)); + Optional player = playerService.getUserByUsername(principal.getName()); + if (!player.isPresent()) { + return "redirect:/"; + } + gameSession = gameSessionService.startNewMultiplayerGame(getLoggedInPlayer(principal), + player.get().getMultiplayerCode()); + if (gameSession == null) + return "redirect:/multiplayerGame"; session.setAttribute("gameSession", gameSession); } @@ -54,6 +131,50 @@ public String getGame(HttpSession session, Model model, Principal principal) { return "game/basicGame"; } + @GetMapping("/multiplayerGame/endGame/{code}") + public String endMultiplayerGame(Model model,@PathVariable String code) { + model.addAttribute("code",code); + return "ranking/multiplayerRanking"; + } + + @GetMapping("/endGameList/{code}") + @ResponseBody + public Map endMultiplayerGameTable(@PathVariable String code) { + Map playerScores = multiplayerSessionService.getPlayersWithScores(Integer.parseInt(code)); + Map playersNameWithScore=new HashMap<>(); + for (Map.Entry player : playerScores.entrySet()) { + String playerName = player.getKey().getUsername(); + String playerScoreValue; + if (player.getValue() == -1) { + playerScoreValue = "N/A"; + } else { + playerScoreValue = "" + player.getValue(); + } + playersNameWithScore.put(playerName, playerScoreValue); + } + return playersNameWithScore; + } + + @GetMapping("/game/lobby/{code}") + @ResponseBody + public List updatePlayerList(@PathVariable String code) { + Map players= multiplayerSessionService.getPlayersWithScores(Integer.parseInt(code)); + List playerNames = new ArrayList<>(); + for (Map.Entry player : players.entrySet()) { + playerNames.add(player.getKey().getUsername()); + } + Collections.sort(playerNames); + return playerNames; + } + + @GetMapping("/game/lobby") + public String createLobby( HttpSession session, Model model) { + int code = Integer.parseInt((String)session.getAttribute("multiplayerCode")); + List players = playerService.getUsersByMultiplayerCode(code); + model.addAttribute("players",players); + model.addAttribute("code",session.getAttribute("multiplayerCode")); + return "/game/lobby"; + } /** * This method is used to check the answer for a specific question @@ -65,56 +186,74 @@ public String getGame(HttpSession session, Model model, Principal principal) { * shown or the timeOutFailure view is shown. */ @GetMapping("/game/{idQuestion}/{idAnswer}") - public String getCheckResult(@PathVariable Long idQuestion, @PathVariable Long idAnswer, Model model, HttpSession session) { - GameSession gameSession = (GameSession) session.getAttribute("gameSession"); + public String getCheckResult(@PathVariable Long idQuestion, @PathVariable Long idAnswer, Model model, HttpSession session, Principal principal) { + GameSession gameSession = (GameSession) session.getAttribute(GAMESESSION_STR); if (gameSession == null) { return "redirect:/game"; } if (!gameSession.hasQuestionId(idQuestion)) { model.addAttribute("score", gameSession.getScore()); - session.removeAttribute("gameSession"); + session.removeAttribute(GAMESESSION_STR); return "redirect:/game"; // if someone wants to exploit the game, just redirect to the game page } if(idAnswer == -1 || getRemainingTime(gameSession) <= 0) { - model.addAttribute("correctAnswer", gameSession.getCurrentQuestion().getCorrectAnswer()); - model.addAttribute("messageKey", "timeRunOut.result"); - model.addAttribute("logoImage", "/images/logo_incorrect.svg"); gameSession.addAnsweredQuestion(gameSession.getCurrentQuestion()); gameSession.addQuestion(false, 0); } else if(questionService.checkAnswer(idQuestion, idAnswer)) { - model.addAttribute("messageKey", "correctAnswer.result"); - model.addAttribute("logoImage", "/images/logo_correct.svg"); - if (!gameSession.isAnswered(gameSession.getCurrentQuestion())) { gameSession.addQuestion(true, getRemainingTime(gameSession)); gameSession.addAnsweredQuestion(gameSession.getCurrentQuestion()); } - } else { - model.addAttribute("correctAnswer", gameSession.getCurrentQuestion().getCorrectAnswer()); - model.addAttribute("messageKey", "failedAnswer.result"); - model.addAttribute("logoImage", "/images/logo_incorrect.svg"); gameSession.addAnsweredQuestion(gameSession.getCurrentQuestion()); gameSession.addQuestion(false, 0); } session.setAttribute("hasJustAnswered", true); gameSession.getNextQuestion(); - return "game/fragments/questionResult"; + return updateGame(model, session, principal); } @GetMapping("/game/update") - public String updateGame(Model model, HttpSession session) { - GameSession gameSession = (GameSession) session.getAttribute("gameSession"); + public String updateGame(Model model, HttpSession session, Principal principal) { + GameSession gameSession = (GameSession) session.getAttribute(GAMESESSION_STR); Question nextQuestion = gameSession.getCurrentQuestion(); + if (nextQuestion == null && gameSession.isMultiplayer()) { + int code = Integer.parseInt((String) session.getAttribute("multiplayerCode")); + List players = playerService.getUsersByMultiplayerCode(code); + + if (!gameSession.isFinished()) { + gameSessionService.endGame(gameSession); + + model.addAttribute("players", players); + model.addAttribute("code", session.getAttribute("multiplayerCode")); + gameSession.setFinished(true); + + Optional player = playerService.getUserByUsername(principal.getName()); + Player p = player.orElse(null); + playerService.setScoreMultiplayerCode(p.getId(),"" + gameSession.getScore()); + multiplayerSessionService.changeScore(p.getMultiplayerCode()+"",p.getId(),gameSession.getScore()); + } else { + model.addAttribute("players", players); + + } + + model.addAttribute("code", session.getAttribute("multiplayerCode")); + return "ranking/multiplayerRanking"; + } + if (nextQuestion == null) { - gameSessionService.endGame(gameSession); - session.removeAttribute("gameSession"); - model.addAttribute("score", gameSession.getScore()); + if (!gameSession.isFinished()) { + gameSessionService.endGame(gameSession); + gameSession.setFinished(true); + } else { + session.removeAttribute(GAMESESSION_STR); + model.addAttribute("score", gameSession.getScore()); + } return "game/fragments/gameFinished"; } @@ -128,21 +267,10 @@ public String updateGame(Model model, HttpSession session) { return "game/fragments/gameFrame"; } - @GetMapping("/game/finished/{points}") - public String finishGame(@PathVariable int points, Model model, HttpSession session) { - GameSession gameSession = (GameSession) session.getAttribute("gameSession"); - if (gameSession != null) { - gameSessionService.endGame(gameSession); - session.removeAttribute("gameSession"); - } - model.addAttribute("score", points); - return "game/gameFinished"; - } - @GetMapping("/game/points") @ResponseBody public String getPoints(HttpSession session) { - GameSession gameSession = (GameSession) session.getAttribute("gameSession"); + GameSession gameSession = (GameSession) session.getAttribute(GAMESESSION_STR); if (gameSession != null) return String.valueOf(gameSession.getScore()); else @@ -152,9 +280,9 @@ public String getPoints(HttpSession session) { @GetMapping("/game/currentQuestion") @ResponseBody public String getCurrentQuestion(HttpSession session) { - GameSession gameSession = (GameSession) session.getAttribute("gameSession"); + GameSession gameSession = (GameSession) session.getAttribute(GAMESESSION_STR); if (gameSession != null) - return String.valueOf(gameSession.getAnsweredQuestions().size()+1); + return String.valueOf(Math.min(gameSession.getAnsweredQuestions().size()+1, GameSessionService.NORMAL_GAME_QUESTION_NUM)); else return "0"; } @@ -179,7 +307,7 @@ private boolean checkUpdateGameSession(GameSession gameSession, HttpSession sess gameSession.addAnsweredQuestion(gameSession.getCurrentQuestion()); if (gameSession.getQuestionsToAnswer().isEmpty()) { gameSessionService.endGame(gameSession); - session.removeAttribute("gameSession"); + session.removeAttribute(GAMESESSION_STR); return true; } } diff --git a/src/main/java/com/uniovi/controllers/HomeController.java b/src/main/java/com/uniovi/controllers/HomeController.java index a89f0f31..e6be7de5 100644 --- a/src/main/java/com/uniovi/controllers/HomeController.java +++ b/src/main/java/com/uniovi/controllers/HomeController.java @@ -9,6 +9,8 @@ import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import java.util.Optional; + @Controller public class HomeController{ private final PlayerService playerService; @@ -27,17 +29,27 @@ public String home(){ @GetMapping("/home/apikey") public String apiKeyHome(Authentication auth, Model model) { - Player player = playerService.getUserByUsername(auth.getName()).get(); - model.addAttribute("apiKey", player.getApiKey()); + Optional playerOpt = playerService.getUserByUsername(auth.getName()); + if (playerOpt.isPresent()) { + Player player = playerOpt.get(); + model.addAttribute("apiKey", player.getApiKey()); + } return "player/apiKeyHome"; } @GetMapping("/home/apikey/create") public String createApiKey(Authentication auth) { - Player player = playerService.getUserByUsername(auth.getName()).get(); - if (player.getApiKey() == null) { - apiKeyService.createApiKey(player); + if (playerService.getUserByUsername(auth.getName()).isPresent()) { + Player player = playerService.getUserByUsername(auth.getName()).get(); + if (player.getApiKey() == null) { + apiKeyService.createApiKey(player); + } } return "redirect:/home/apikey"; } + + @GetMapping("/instructions") + public String instructions(){ + return "instructions"; + } } diff --git a/src/main/java/com/uniovi/controllers/PlayersController.java b/src/main/java/com/uniovi/controllers/PlayersController.java index 27bb9564..20cae551 100644 --- a/src/main/java/com/uniovi/controllers/PlayersController.java +++ b/src/main/java/com/uniovi/controllers/PlayersController.java @@ -1,13 +1,21 @@ package com.uniovi.controllers; +import com.fasterxml.jackson.core.util.DefaultIndenter; +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.uniovi.configuration.SecurityConfig; +import com.uniovi.dto.RoleDto; +import com.uniovi.entities.Associations; import com.uniovi.entities.GameSession; import com.uniovi.entities.Player; -import com.uniovi.services.GameSessionService; -import com.uniovi.services.PlayerService; +import com.uniovi.entities.Role; +import com.uniovi.services.*; import com.uniovi.validators.SignUpValidator; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; @@ -16,27 +24,32 @@ import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.*; import com.uniovi.dto.PlayerDto; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestParam; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; import java.security.Principal; import java.util.Optional; +import java.util.List; @Controller public class PlayersController { private final PlayerService playerService; + private final RoleService roleService; + private QuestionService questionService; private final SignUpValidator signUpValidator; private final GameSessionService gameSessionService; @Autowired - public PlayersController(PlayerService playerService, SignUpValidator signUpValidator, GameSessionService gameSessionService) { + public PlayersController(PlayerService playerService, SignUpValidator signUpValidator, GameSessionService gameSessionService, + RoleService roleService, QuestionService questionService) { this.playerService = playerService; this.signUpValidator = signUpValidator; this.gameSessionService = gameSessionService; + this.roleService = roleService; } @GetMapping("/signup") @@ -80,7 +93,6 @@ public String showLoginForm(Model model, @RequestParam(value = "error", required HttpSession session) { if (error != null) { model.addAttribute("error", session.getAttribute("loginErrorMessage")); - System.out.println(session.getAttribute("loginErrorMessage")); } if (SecurityConfig.isAuthenticated()) @@ -100,6 +112,7 @@ public String showGlobalRanking(Pageable pageable, Model model) { model.addAttribute("ranking", ranking.getContent()); model.addAttribute("page", ranking); + model.addAttribute("num", pageable.getPageSize()); return "ranking/globalRanking"; } @@ -117,7 +130,158 @@ public String showPlayerRanking(Pageable pageable, Model model, Principal princi model.addAttribute("ranking", ranking.getContent()); model.addAttribute("page", ranking); + model.addAttribute("num", pageable.getPageSize()); return "ranking/playerRanking"; } + + // ----- Admin endpoints ----- + + @GetMapping("/player/admin") + public String showAdminPanel(Model model) { + return "player/admin/admin"; + } + + @GetMapping("/player/admin/userManagement") + public String showUserManagementFragment(Model model, Pageable pageable) { + model.addAttribute("endpoint", "/player/admin/userManagement"); + Page users = playerService.getPlayersPage(pageable); + model.addAttribute("page", users); + model.addAttribute("users", users.getContent()); + + return "player/admin/userManagement"; + } + + @GetMapping("/player/admin/deleteUser") + @ResponseBody + public String deleteUser(HttpServletResponse response, @RequestParam String username, Principal principal) { + Player player = playerService.getUserByUsername(username).orElse(null); + if (player == null) { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + return "User not found"; + } + + if (principal.getName().equals(player.getUsername())) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + return "You can't delete yourself"; + } + + playerService.deletePlayer(player.getId()); + return "User deleted"; + } + + @GetMapping("/player/admin/changePassword") + @ResponseBody + public String changePassword(HttpServletResponse response, @RequestParam String username, @RequestParam String password) { + Player player = playerService.getUserByUsername(username).orElse(null); + if (player == null) { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + return "User not found"; + } + + playerService.updatePassword(player, password); + return "User password changed"; + } + + @GetMapping("/player/admin/getRoles") + @ResponseBody + public String getRoles(@RequestParam String username) { + List roles = roleService.getAllRoles(); + Player player = playerService.getUserByUsername(username).orElse(null); + + roles.remove(roleService.getRole("ROLE_USER")); + + if (player == null) { + return "{}"; + } + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode rolesJson = mapper.createObjectNode(); + for (Role role : roles) { + boolean hasRole = player.getRoles().contains(role); + rolesJson.put(role.getName(), hasRole); + } + + return rolesJson.toString(); + } + + @GetMapping("/player/admin/changeRoles") + @ResponseBody + public String changeRoles(HttpServletResponse response, @RequestParam String username, @RequestParam String roles) { + Player player = playerService.getUserByUsername(username).orElse(null); + if (player == null) { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + return "User not found"; + } + + JsonNode rolesJson; + try { + rolesJson = new ObjectMapper().readTree(roles); + } catch (Exception e) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return "Invalid roles"; + } + + rolesJson.fieldNames().forEachRemaining(roleName -> { + boolean hasRole = rolesJson.get(roleName).asBoolean(); + + Role role = roleService.getRole(roleName); + if (role == null && !hasRole) { + return; + } else if (role == null) { + role = roleService.addRole(new RoleDto(roleName)); + } + + if (hasRole) { + Associations.PlayerRole.addRole(player, role); + } else { + Associations.PlayerRole.removeRole(player, role); + } + }); + + playerService.savePlayer(player); + return "User roles changed"; + } + + @GetMapping("/player/admin/questionManagement") + public String showQuestionManagementFragment(Model model) throws IOException { + File jsonFile = new File(QuestionGeneratorService.JSON_FILE_PATH); + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode json = objectMapper.readTree(jsonFile); + model.addAttribute("jsonContent", json.toString()); + + return "player/admin/questionManagement"; + } + + @GetMapping("/player/admin/deleteAllQuestions") + @ResponseBody + public String deleteAllQuestions() throws IOException { + questionService.deleteAllQuestions(); + return "Questions deleted"; + } + + @GetMapping("/player/admin/saveQuestions") + @ResponseBody + public String saveQuestions(HttpServletResponse response, @RequestParam String json) throws IOException { + try { + JsonNode node = new ObjectMapper().readTree(json); + DefaultPrettyPrinter printer = new DefaultPrettyPrinter(); + DefaultPrettyPrinter.Indenter indenter = new DefaultIndenter(); + printer.indentObjectsWith(indenter); // Indent JSON objects + printer.indentArraysWith(indenter); // Indent JSON arrays + + ObjectMapper mapper = new ObjectMapper(); + mapper.writer(printer).writeValue(new FileOutputStream(QuestionGeneratorService.JSON_FILE_PATH), node); + return "Questions saved"; + } + catch (Exception e) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return "Invalid JSON"; + } + } + + @GetMapping("/player/admin/monitoring") + public String showMonitoring(Model model) { + return "player/admin/monitoring"; + } } diff --git a/src/main/java/com/uniovi/controllers/api/PlayerApiController.java b/src/main/java/com/uniovi/controllers/api/PlayerApiController.java index 677cac9e..74641915 100644 --- a/src/main/java/com/uniovi/controllers/api/PlayerApiController.java +++ b/src/main/java/com/uniovi/controllers/api/PlayerApiController.java @@ -30,7 +30,6 @@ import org.springframework.validation.SimpleErrors; import org.springframework.web.bind.annotation.*; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; diff --git a/src/main/java/com/uniovi/controllers/api/QuestionsApiController.java b/src/main/java/com/uniovi/controllers/api/QuestionsApiController.java index cdff40cf..68b3b5a7 100644 --- a/src/main/java/com/uniovi/controllers/api/QuestionsApiController.java +++ b/src/main/java/com/uniovi/controllers/api/QuestionsApiController.java @@ -160,7 +160,7 @@ public String addQuestion(HttpServletResponse response, @RequestHeader("API-KEY" return objectMapper.writeValueAsString(error); } - if (questionDto.getOptions().stream().anyMatch(option -> option.isCorrect())) { + if (questionDto.getOptions().stream().anyMatch(AnswerDto::isCorrect)) { questionDto.setCorrectAnswer(questionDto.getOptions().stream().filter(option -> option.isCorrect()).findFirst().get()); } diff --git a/src/main/java/com/uniovi/dto/AnswerDto.java b/src/main/java/com/uniovi/dto/AnswerDto.java index 026eede6..304ee640 100644 --- a/src/main/java/com/uniovi/dto/AnswerDto.java +++ b/src/main/java/com/uniovi/dto/AnswerDto.java @@ -6,6 +6,7 @@ @Getter @Setter @NoArgsConstructor +@AllArgsConstructor @ToString public class AnswerDto { diff --git a/src/main/java/com/uniovi/dto/CategoryDto.java b/src/main/java/com/uniovi/dto/CategoryDto.java index fc87530e..57aef22e 100644 --- a/src/main/java/com/uniovi/dto/CategoryDto.java +++ b/src/main/java/com/uniovi/dto/CategoryDto.java @@ -11,6 +11,7 @@ @Getter @Setter @NoArgsConstructor +@AllArgsConstructor public class CategoryDto { @Schema(description = "The name of the category", example = "Geography") diff --git a/src/main/java/com/uniovi/dto/PlayerDto.java b/src/main/java/com/uniovi/dto/PlayerDto.java index 027b26fa..2e3d6571 100644 --- a/src/main/java/com/uniovi/dto/PlayerDto.java +++ b/src/main/java/com/uniovi/dto/PlayerDto.java @@ -22,6 +22,9 @@ public class PlayerDto { @Schema(hidden = true) private String passwordConfirm; + //@Schema(description = "code of group of the player", example = "5565") + //private Integer multiplayerCode; + @Schema(description = "Roles of the player", example = "[\"ROLE_USER\"]") private String[] roles; } diff --git a/src/main/java/com/uniovi/dto/QuestionDto.java b/src/main/java/com/uniovi/dto/QuestionDto.java index d97efb5f..7d4ce0e0 100644 --- a/src/main/java/com/uniovi/dto/QuestionDto.java +++ b/src/main/java/com/uniovi/dto/QuestionDto.java @@ -1,8 +1,10 @@ package com.uniovi.dto; +import com.uniovi.entities.Question; import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; +import java.util.ArrayList; import java.util.List; @Getter @@ -26,4 +28,13 @@ public class QuestionDto { @Schema(description = "The language of the question") private String language; + + public QuestionDto (Question question) { + statement = question.getStatement(); + options = question.getOptions().stream().map(a -> new AnswerDto(a.getText(), a.isCorrect())).toList(); + correctAnswer = new AnswerDto(question.getCorrectAnswer().getText(), question.getCorrectAnswer().isCorrect()); + category = new CategoryDto(question.getCategory().getName(), question.getCategory().getDescription(), new ArrayList<>()); + language = question.getLanguage(); + } + } diff --git a/src/main/java/com/uniovi/entities/Associations.java b/src/main/java/com/uniovi/entities/Associations.java index 73218e2f..6046ffe1 100644 --- a/src/main/java/com/uniovi/entities/Associations.java +++ b/src/main/java/com/uniovi/entities/Associations.java @@ -110,6 +110,9 @@ public static class QuestionAnswers { public static void addAnswer(Question question, List answer) { for (Answer a : answer) { a.setQuestion(question); + if (a.isCorrect()) { + question.setCorrectAnswer(a); + } } question.getOptions().addAll(answer); } @@ -125,13 +128,8 @@ public static void removeAnswer(Question question, List answer) { for (Answer a : answer) { a.setQuestion(null); } + question.setCorrectAnswer(null); } - //public static void removeAnswer(Question question, List answer) { - // question.getOptions().remove(answer); - //for (Answer a : answer) { - // a.setQuestion(null); - //} - //} } public static class QuestionsCategory { diff --git a/src/main/java/com/uniovi/entities/Category.java b/src/main/java/com/uniovi/entities/Category.java index 28e798c3..f68ba88f 100644 --- a/src/main/java/com/uniovi/entities/Category.java +++ b/src/main/java/com/uniovi/entities/Category.java @@ -33,6 +33,10 @@ public Category(String name, String description) { this.description = description; } + public Category(String categoryName) { + this.name = categoryName; + } + @Override public String toString() { return name; diff --git a/src/main/java/com/uniovi/entities/GameSession.java b/src/main/java/com/uniovi/entities/GameSession.java index a4285788..75ec5a51 100644 --- a/src/main/java/com/uniovi/entities/GameSession.java +++ b/src/main/java/com/uniovi/entities/GameSession.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.uniovi.interfaces.JsonEntity; import jakarta.persistence.*; -import jakarta.validation.constraints.NotEmpty; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -46,6 +45,12 @@ public class GameSession implements JsonEntity, Serializable { @Transient private Question currentQuestion; + @Transient + private boolean isMultiplayer = false; + + @Transient + private boolean isFinished = false; + public GameSession(Player player, List questions) { this.player = player; this.questionsToAnswer = questions; @@ -76,7 +81,7 @@ public boolean isAnswered(Question question) { } public Question getNextQuestion() { - if(questionsToAnswer.isEmpty()) { + if (questionsToAnswer.isEmpty()) { currentQuestion = null; return null; } diff --git a/src/main/java/com/uniovi/entities/MultiplayerSession.java b/src/main/java/com/uniovi/entities/MultiplayerSession.java new file mode 100644 index 00000000..e09597a4 --- /dev/null +++ b/src/main/java/com/uniovi/entities/MultiplayerSession.java @@ -0,0 +1,34 @@ +package com.uniovi.entities; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Getter // getters para todas las propiedades +@Setter // setters para todas las propiedades +@Entity +public class MultiplayerSession { + @Id + @GeneratedValue + private Long id; + @Column + private String multiplayerCode; + + @ElementCollection + @Column + private Map playerScores = new HashMap<>(); + + public MultiplayerSession() {} + + public MultiplayerSession(String code, Player p) { + this.multiplayerCode=code; + playerScores.put(p,-1); + } + + public void addPlayer(Player p){ + playerScores.put(p,-1); + } +} diff --git a/src/main/java/com/uniovi/entities/Player.java b/src/main/java/com/uniovi/entities/Player.java index 491a8bd8..97b93fd2 100644 --- a/src/main/java/com/uniovi/entities/Player.java +++ b/src/main/java/com/uniovi/entities/Player.java @@ -33,6 +33,13 @@ public class Player implements JsonEntity { @NotEmpty private String password; + @Column + private Integer multiplayerCode; + + @Column + private String scoreMultiplayerCode; + + @ManyToMany(cascade = CascadeType.MERGE, fetch = FetchType.EAGER) private Set roles = new HashSet<>(); @@ -42,6 +49,8 @@ public class Player implements JsonEntity { @OneToOne(cascade = CascadeType.ALL, mappedBy = "player") private ApiKey apiKey; + + // Transient: no se almacena en la base de datos @Transient private String passwordConfirm; diff --git a/src/main/java/com/uniovi/entities/Question.java b/src/main/java/com/uniovi/entities/Question.java index 10ac54f3..1fa2b9c2 100644 --- a/src/main/java/com/uniovi/entities/Question.java +++ b/src/main/java/com/uniovi/entities/Question.java @@ -25,7 +25,6 @@ public class Question implements JsonEntity { public static final String SPANISH = "es"; public static final String FRENCH = "fr"; - @Id @GeneratedValue private Long id; @@ -78,8 +77,9 @@ public boolean isCorrectAnswer(Answer answer){ return answer.isCorrect(); } - public void scrambleOptions(){ + public List returnScrambledOptions(){ Collections.shuffle(options); + return options; } @Override diff --git a/src/main/java/com/uniovi/entities/Role.java b/src/main/java/com/uniovi/entities/Role.java index c767c425..0d4d45c0 100644 --- a/src/main/java/com/uniovi/entities/Role.java +++ b/src/main/java/com/uniovi/entities/Role.java @@ -22,4 +22,9 @@ public class Role { public Role(String name) { this.name = name; } + + @Override + public String toString() { + return name; + } } diff --git a/src/main/java/com/uniovi/repositories/MultiplayerSessionRepository.java b/src/main/java/com/uniovi/repositories/MultiplayerSessionRepository.java new file mode 100644 index 00000000..fd9e926e --- /dev/null +++ b/src/main/java/com/uniovi/repositories/MultiplayerSessionRepository.java @@ -0,0 +1,17 @@ +package com.uniovi.repositories; + +import com.uniovi.entities.GameSession; +import com.uniovi.entities.MultiplayerSession; +import com.uniovi.entities.Player; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface MultiplayerSessionRepository extends CrudRepository { + MultiplayerSession findByMultiplayerCode(String code); +} + diff --git a/src/main/java/com/uniovi/repositories/PlayerRepository.java b/src/main/java/com/uniovi/repositories/PlayerRepository.java index 77c2d9f8..254adb52 100644 --- a/src/main/java/com/uniovi/repositories/PlayerRepository.java +++ b/src/main/java/com/uniovi/repositories/PlayerRepository.java @@ -1,9 +1,16 @@ package com.uniovi.repositories; import com.uniovi.entities.Player; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.repository.CrudRepository; public interface PlayerRepository extends CrudRepository { Player findByEmail(String email); Player findByUsername(String nickname); + @Query("SELECT player FROM Player player WHERE player.multiplayerCode=:multiplayerCode") + Iterable findAllByMultiplayerCode(int multiplayerCode); + + Page findAll(Pageable pageable); } diff --git a/src/main/java/com/uniovi/repositories/QuestionRepository.java b/src/main/java/com/uniovi/repositories/QuestionRepository.java index e7dfa216..407f233b 100644 --- a/src/main/java/com/uniovi/repositories/QuestionRepository.java +++ b/src/main/java/com/uniovi/repositories/QuestionRepository.java @@ -7,7 +7,6 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; -import java.util.Optional; import java.util.List; public interface QuestionRepository extends CrudRepository { Question findByStatement(String statement); diff --git a/src/main/java/com/uniovi/services/AnswerService.java b/src/main/java/com/uniovi/services/AnswerService.java index ff2ddd44..015aebc2 100644 --- a/src/main/java/com/uniovi/services/AnswerService.java +++ b/src/main/java/com/uniovi/services/AnswerService.java @@ -1,7 +1,6 @@ package com.uniovi.services; import com.uniovi.entities.Answer; -import com.uniovi.entities.Category; import com.uniovi.entities.Question; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/uniovi/services/CategoryService.java b/src/main/java/com/uniovi/services/CategoryService.java index 52f29eb7..9df8c82b 100644 --- a/src/main/java/com/uniovi/services/CategoryService.java +++ b/src/main/java/com/uniovi/services/CategoryService.java @@ -1,7 +1,6 @@ package com.uniovi.services; import com.uniovi.entities.Category; -import com.uniovi.entities.Question; import org.springframework.stereotype.Service; import java.util.List; diff --git a/src/main/java/com/uniovi/services/CustomUserDetailsService.java b/src/main/java/com/uniovi/services/CustomUserDetailsService.java index 2a1535ad..189ca630 100644 --- a/src/main/java/com/uniovi/services/CustomUserDetailsService.java +++ b/src/main/java/com/uniovi/services/CustomUserDetailsService.java @@ -11,7 +11,6 @@ import org.springframework.stereotype.Service; import java.util.Collection; -import java.util.stream.Collectors; @Service("userDetailsService") public class CustomUserDetailsService implements UserDetailsService { @@ -36,9 +35,8 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx } private Collection< ? extends GrantedAuthority> mapRolesToAuthorities(Collection roles) { - Collection < ? extends GrantedAuthority> mapRoles = roles.stream() + return roles.stream() .map(role -> new SimpleGrantedAuthority(role.getName())) - .collect(Collectors.toList()); - return mapRoles; + .toList(); } } diff --git a/src/main/java/com/uniovi/services/GameSessionService.java b/src/main/java/com/uniovi/services/GameSessionService.java index e3eb4c55..83166040 100644 --- a/src/main/java/com/uniovi/services/GameSessionService.java +++ b/src/main/java/com/uniovi/services/GameSessionService.java @@ -2,15 +2,13 @@ import com.uniovi.entities.GameSession; import com.uniovi.entities.Player; -import com.uniovi.services.impl.GameSessionImpl; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import java.util.HashMap; import java.util.List; public interface GameSessionService { + Integer NORMAL_GAME_QUESTION_NUM = 4; /** * Return the list of GameSessions @@ -43,5 +41,8 @@ public interface GameSessionService { Page getPlayerRanking(Pageable pageable, Player player); GameSession startNewGame(Player player); + + GameSession startNewMultiplayerGame(Player player, int code); + void endGame(GameSession gameSession); } diff --git a/src/main/java/com/uniovi/services/InsertSampleDataService.java b/src/main/java/com/uniovi/services/InsertSampleDataService.java index f51a0ed8..2a00d6b3 100644 --- a/src/main/java/com/uniovi/services/InsertSampleDataService.java +++ b/src/main/java/com/uniovi/services/InsertSampleDataService.java @@ -1,108 +1,38 @@ package com.uniovi.services; -import com.uniovi.components.MultipleQuestionGenerator; -import com.uniovi.components.generators.QuestionGenerator; -import com.uniovi.components.generators.geography.BorderQuestionGenerator; -import com.uniovi.components.generators.geography.CapitalQuestionGenerator; -import com.uniovi.components.generators.geography.ContinentQuestionGeneration; import com.uniovi.dto.PlayerDto; -import com.uniovi.entities.Associations; -import com.uniovi.entities.GameSession; -import com.uniovi.entities.Player; -import com.uniovi.entities.Question; -import com.uniovi.repositories.GameSessionRepository; -import com.uniovi.repositories.QuestionRepository; -import jakarta.annotation.PostConstruct; import jakarta.transaction.Transactional; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.core.env.Environment; import org.springframework.stereotype.Service; -import java.io.IOException; -import java.time.LocalDateTime; -import java.util.*; +import java.util.Arrays; @Service public class InsertSampleDataService { private final PlayerService playerService; - private final QuestionService questionService; - private final CategoryService categoryService; - private final QuestionRepository questionRepository; - private final GameSessionRepository gameSessionRepository; - private Environment environment; + private final Environment environment; - private Logger log = LoggerFactory.getLogger(InsertSampleDataService.class); - - public InsertSampleDataService(PlayerService playerService, QuestionService questionService, - CategoryService categoryService, QuestionRepository questionRepository, - GameSessionRepository gameSessionRepository, Environment environment) { + public InsertSampleDataService(PlayerService playerService, Environment environment) { this.playerService = playerService; - this.questionService = questionService; - this.categoryService = categoryService; - this.questionRepository = questionRepository; - this.gameSessionRepository = gameSessionRepository; this.environment = environment; } @Transactional @EventListener(ApplicationReadyEvent.class) // Uncomment this line to insert sample data on startup - public void insertSampleQuestions() throws InterruptedException, IOException { - if (!playerService.getUserByEmail("test@test.com").isPresent()) { + public void insertSampleQuestions() { + if (playerService.getUserByEmail("test@test.com").isEmpty()) { PlayerDto player = new PlayerDto(); player.setEmail("test@test.com"); player.setUsername("test"); player.setPassword("test"); - player.setRoles(new String[]{"ROLE_USER"}); + if (Arrays.asList(environment.getActiveProfiles()).contains("test")) + player.setRoles(new String[]{"ROLE_USER", "ROLE_ADMIN"}); + else + player.setRoles(new String[]{"ROLE_USER"}); playerService.generateApiKey(playerService.addNewPlayer(player)); } - - if (Arrays.stream(environment.getActiveProfiles()).anyMatch(env -> (env.equalsIgnoreCase("test")))) { - log.info("Test profile active, skipping sample data insertion"); - return; - } - - generateSampleData(); - } - - @Transactional - public void generateTestQuestions() { - questionRepository.deleteAll(); - questionService.testQuestions(4); } - @Transactional - public void generateSampleData() throws InterruptedException, IOException { - - questionRepository.deleteAll(); - - MultipleQuestionGenerator allQuestionGenerator = new MultipleQuestionGenerator( - new ContinentQuestionGeneration(categoryService, Question.ENGLISH), - new CapitalQuestionGenerator(categoryService, Question.ENGLISH), - new BorderQuestionGenerator(categoryService, Question.ENGLISH) - ); - List questionsEn = allQuestionGenerator.getQuestions(); - questionsEn.forEach(questionService::addNewQuestion); - - allQuestionGenerator = new MultipleQuestionGenerator( - new ContinentQuestionGeneration(categoryService, Question.SPANISH), - new CapitalQuestionGenerator(categoryService, Question.SPANISH), - new BorderQuestionGenerator(categoryService, Question.SPANISH) - ); - List questionsEs = allQuestionGenerator.getQuestions(); - questionsEs.forEach(questionService::addNewQuestion); - - allQuestionGenerator = new MultipleQuestionGenerator( - new ContinentQuestionGeneration(categoryService, Question.FRENCH), - new CapitalQuestionGenerator(categoryService, Question.FRENCH), - new BorderQuestionGenerator(categoryService, Question.FRENCH) - ); - List questionsFr = allQuestionGenerator.getQuestions(); - questionsFr.forEach(questionService::addNewQuestion); - - log.info("Sample questions inserted"); - } -} +} \ No newline at end of file diff --git a/src/main/java/com/uniovi/services/MultiplayerSessionService.java b/src/main/java/com/uniovi/services/MultiplayerSessionService.java new file mode 100644 index 00000000..d9e82736 --- /dev/null +++ b/src/main/java/com/uniovi/services/MultiplayerSessionService.java @@ -0,0 +1,27 @@ +package com.uniovi.services; + +import com.uniovi.entities.GameSession; +import com.uniovi.entities.MultiplayerSession; +import com.uniovi.entities.Player; +import com.uniovi.entities.Question; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +@Service +public interface MultiplayerSessionService { + + Map getPlayersWithScores(int multiplayerCode); + void multiCreate(String code, Long id); + + void addToLobby(String code, Long id); + + void changeScore(String code,Long id,int score); + + boolean existsCode(String code); + + List getQuestions(String code); +} diff --git a/src/main/java/com/uniovi/services/PlayerService.java b/src/main/java/com/uniovi/services/PlayerService.java index f59669e3..e8a19a29 100644 --- a/src/main/java/com/uniovi/services/PlayerService.java +++ b/src/main/java/com/uniovi/services/PlayerService.java @@ -2,12 +2,11 @@ import com.uniovi.dto.PlayerDto; import com.uniovi.entities.Player; -import com.uniovi.repositories.PlayerRepository; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import java.util.List; -import java.util.ArrayList; import java.util.Optional; @Service @@ -26,6 +25,13 @@ public interface PlayerService { */ List getUsers(); + + /** + * Get all the players in the database with same multiplayerCode + * @return A list with the players + */ + List getUsersByMultiplayerCode(int multiplayerCode); + /** * Get a player by its id * @param id The id of the player @@ -67,9 +73,44 @@ public interface PlayerService { */ void updatePlayer(Long id, PlayerDto playerDto); + /** + * Update the multiplayerCode of a player + * @param id The id of the player to update + * @param code The new multiplayerCode of the player + */ + boolean changeMultiplayerCode(Long id, String code); + + String getScoreMultiplayerCode(Long id); + + void setScoreMultiplayerCode(Long id, String score); + + int createMultiplayerGame(Long id); + + void deleteMultiplayerCode(Long id); + /** * Delete a player from the database * @param id The id of the player to delete */ void deletePlayer(Long id); + + /** + * Get a page with all the players in the database + * @param pageable The page information + * @return A page with all the players + */ + Page getPlayersPage(Pageable pageable); + + /** + * Update the password of a player + * @param player The player to update the password + * @param password The new password + */ + void updatePassword(Player player, String password); + + /** + * Save a player in the database + * @param player The player to save + */ + void savePlayer(Player player); } diff --git a/src/main/java/com/uniovi/services/QuestionGeneratorService.java b/src/main/java/com/uniovi/services/QuestionGeneratorService.java new file mode 100644 index 00000000..d4e35327 --- /dev/null +++ b/src/main/java/com/uniovi/services/QuestionGeneratorService.java @@ -0,0 +1,139 @@ +package com.uniovi.services; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.uniovi.components.generators.QuestionGenerator; +import com.uniovi.components.generators.QuestionGeneratorV2; +import com.uniovi.dto.QuestionDto; +import com.uniovi.entities.Answer; +import com.uniovi.entities.Category; +import com.uniovi.entities.Question; +import com.uniovi.services.impl.QuestionServiceImpl; +import jakarta.transaction.Transactional; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.Environment; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; +import java.util.List; + +@Service +public class QuestionGeneratorService { + + private final QuestionService questionService; + + public static final String JSON_FILE_PATH = "src/main/resources/static/JSON/QuestionTemplates.json"; + + private Deque types = new ArrayDeque<>(); + + private JsonNode json; + + private Environment environment; + + private final Logger log = LoggerFactory.getLogger(QuestionGeneratorService.class); + + private boolean started; + + public QuestionGeneratorService(QuestionService questionService, Environment environment) throws IOException { + this.questionService = questionService; + this.environment = environment; + ((QuestionServiceImpl)questionService).setQuestionGeneratorService(this); + parseQuestionTypes(); + this.started = true; + } + + private void parseQuestionTypes() throws IOException { + File jsonFile = new File(JSON_FILE_PATH); + ObjectMapper objectMapper = new ObjectMapper(); + json = objectMapper.readTree(jsonFile); + JsonNode categories = json.findValue("categories"); + for (JsonNode category : categories) { + String categoryName = category.get("name").textValue(); + Category cat = new Category(categoryName); + JsonNode questionsNode = category.findValue("questions"); + for (JsonNode question : questionsNode) { + types.push(new QuestionType(question, cat)); + } + } + } + + @Scheduled(fixedRate = 86400000, initialDelay = 86400000) + public void generateAllQuestions() throws IOException { + started = true; + resetGeneration(); + } + + @Scheduled(fixedRate = 150000) + @Transactional + public void generateQuestions() throws IOException, InterruptedException { + if (types.isEmpty()) { + return; + } + + if (started) { + started = false; + questionService.deleteAllQuestions(); + } + + if (Arrays.stream(environment.getActiveProfiles()).anyMatch(env -> (env.equalsIgnoreCase("test")))) { + log.info("Test profile active, skipping sample data insertion"); + return; + } + + QuestionGenerator qgen = new QuestionGeneratorV2(json); + QuestionType type = types.pop(); + List questions; + + List qsp = qgen.getQuestions(Question.SPANISH, type.getQuestion(), type.getCategory()); + questions = qsp.stream().map(QuestionDto::new).toList(); + questions.forEach(questionService::addNewQuestion); + + List qen = qgen.getQuestions(Question.ENGLISH, type.getQuestion(), type.getCategory()); + questions = qen.stream().map(QuestionDto::new).toList(); + questions.forEach(questionService::addNewQuestion); + + List qfr = qgen.getQuestions(Question.FRENCH, type.getQuestion(), type.getCategory()); + questions = qfr.stream().map(QuestionDto::new).toList(); + questions.forEach(questionService::addNewQuestion); + } + + @Transactional + public void generateTestQuestions() throws IOException, InterruptedException { + QuestionGenerator qgen = new QuestionGeneratorV2(json); + QuestionType type = types.pop(); + List questions; + + List qsp = qgen.getQuestions(Question.SPANISH, type.getQuestion(), type.getCategory()); + questions = qsp.stream().map(QuestionDto::new).toList(); + questions.forEach(questionService::addNewQuestion); + } + + @Transactional + public void generateTestQuestions(String cat) { + Answer a1 = new Answer("1", true); + List answers = List.of(a1, new Answer("2", false), new Answer("3", false), new Answer("4", false)); + Question q = new Question("Statement", answers, a1, new Category(cat), "es"); + questionService.addNewQuestion(new QuestionDto(q)); + } + + public void resetGeneration() throws IOException { + types.clear(); + parseQuestionTypes(); + } + + @Getter + @AllArgsConstructor + private static class QuestionType { + private final JsonNode question; + private final Category category; + } +} diff --git a/src/main/java/com/uniovi/services/QuestionService.java b/src/main/java/com/uniovi/services/QuestionService.java index 7eb7d422..1cea8683 100644 --- a/src/main/java/com/uniovi/services/QuestionService.java +++ b/src/main/java/com/uniovi/services/QuestionService.java @@ -3,11 +3,11 @@ import com.uniovi.dto.QuestionDto; import com.uniovi.entities.Category; import com.uniovi.entities.Question; -import jakarta.transaction.Transactional; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import org.springframework.data.domain.Pageable; +import java.io.IOException; import java.util.List; import java.util.Optional; @@ -99,10 +99,7 @@ public interface QuestionService { void deleteQuestion(Long id); /** - * Get some test questions - * - * @param num The number of questions to get - * @return The questions selected + * Delete all the questions */ - List testQuestions(int num); + void deleteAllQuestions() throws IOException; } diff --git a/src/main/java/com/uniovi/services/RoleService.java b/src/main/java/com/uniovi/services/RoleService.java index e620105f..fd196940 100644 --- a/src/main/java/com/uniovi/services/RoleService.java +++ b/src/main/java/com/uniovi/services/RoleService.java @@ -20,4 +20,10 @@ public interface RoleService { * @return The role with the given name */ Role getRole(String name); + + /** + * Get all the roles in the database + * @return A list with all the roles + */ + List getAllRoles(); } diff --git a/src/main/java/com/uniovi/services/impl/CategoryServiceImpl.java b/src/main/java/com/uniovi/services/impl/CategoryServiceImpl.java index 636bffe5..cf3c3545 100644 --- a/src/main/java/com/uniovi/services/impl/CategoryServiceImpl.java +++ b/src/main/java/com/uniovi/services/impl/CategoryServiceImpl.java @@ -52,4 +52,4 @@ public void init() { } } } -} +} \ No newline at end of file diff --git a/src/main/java/com/uniovi/services/impl/GameSessionImpl.java b/src/main/java/com/uniovi/services/impl/GameSessionImpl.java index d7e2ad3a..2e665fa6 100644 --- a/src/main/java/com/uniovi/services/impl/GameSessionImpl.java +++ b/src/main/java/com/uniovi/services/impl/GameSessionImpl.java @@ -1,13 +1,11 @@ package com.uniovi.services.impl; -import com.uniovi.entities.Associations; -import com.uniovi.entities.Player; +import com.uniovi.entities.*; import com.uniovi.repositories.GameSessionRepository; -import com.uniovi.entities.GameSession; import com.uniovi.services.GameSessionService; +import com.uniovi.services.MultiplayerSessionService; import com.uniovi.services.QuestionService; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -16,14 +14,16 @@ @Service public class GameSessionImpl implements GameSessionService { - public static final Integer NORMAL_GAME_QUESTION_NUM = 4; private final GameSessionRepository gameSessionRepository; private final QuestionService questionService; + private final MultiplayerSessionService multiplayerSessionService; - public GameSessionImpl(GameSessionRepository gameSessionRepository, QuestionService questionService) { + public GameSessionImpl(GameSessionRepository gameSessionRepository, QuestionService questionService, + MultiplayerSessionService multiplayerSessionService) { this.gameSessionRepository = gameSessionRepository; this.questionService = questionService; + this.multiplayerSessionService = multiplayerSessionService; } @Override @@ -33,7 +33,6 @@ public List getGameSessions() { @Override public List getGameSessionsByPlayer(Player player) { - return gameSessionRepository.findAllByPlayer(player); } @@ -51,6 +50,17 @@ public GameSession startNewGame(Player player) { return new GameSession(player, questionService.getRandomQuestions(NORMAL_GAME_QUESTION_NUM)); } + @Override + public GameSession startNewMultiplayerGame(Player player, int code) { + List qs = multiplayerSessionService.getQuestions(String.valueOf(code)); + if (qs == null) + return null; + + GameSession sess = new GameSession(player, qs); + sess.setMultiplayer(true); + return sess; + } + @Override public void endGame(GameSession gameSession) { Associations.PlayerGameSession.addGameSession(gameSession.getPlayer(), gameSession); diff --git a/src/main/java/com/uniovi/services/impl/MultiplayerSessionImpl.java b/src/main/java/com/uniovi/services/impl/MultiplayerSessionImpl.java new file mode 100644 index 00000000..ecbf1a94 --- /dev/null +++ b/src/main/java/com/uniovi/services/impl/MultiplayerSessionImpl.java @@ -0,0 +1,97 @@ +package com.uniovi.services.impl; + +import com.uniovi.entities.MultiplayerSession; +import com.uniovi.entities.Player; +import com.uniovi.entities.Question; +import com.uniovi.repositories.MultiplayerSessionRepository; +import com.uniovi.repositories.PlayerRepository; +import com.uniovi.services.GameSessionService; +import com.uniovi.services.MultiplayerSessionService; +import com.uniovi.services.QuestionService; +import jakarta.transaction.Transactional; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Service +public class MultiplayerSessionImpl implements MultiplayerSessionService { + private final PlayerRepository playerRepository; + private final MultiplayerSessionRepository multiplayerSessionRepository; + private final QuestionService questionService; + + private Map> multiplayerSessionQuestions = new HashMap<>(); + + + public MultiplayerSessionImpl(PlayerRepository playerRepository, MultiplayerSessionRepository multiplayerSessionRepository, + QuestionService questionService) { + this.playerRepository = playerRepository; + this.multiplayerSessionRepository = multiplayerSessionRepository; + this.questionService = questionService; + } + + @Override + @Transactional + public Map getPlayersWithScores(int multiplayerCode) { + MultiplayerSession session = multiplayerSessionRepository.findByMultiplayerCode(String.valueOf(multiplayerCode)); + Map playerScores = session.getPlayerScores(); + + // Ordenar los jugadores por puntuaciĂłn de mayor a menor + List sortedPlayers = playerScores.entrySet().stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) + .map(Map.Entry::getKey) + .toList(); + + Map playersSorted = new HashMap<>(); + for (Player player : sortedPlayers) { + playersSorted.put(player,playerScores.get(player)); + } + return playersSorted; + } + + @Override + public void multiCreate(String code, Long id) { + Player p = playerRepository.findById(id).orElse(null); + + if (p != null) { + multiplayerSessionRepository.save(new MultiplayerSession(code, p)); + multiplayerSessionQuestions.put(code, questionService.getRandomQuestions(GameSessionService.NORMAL_GAME_QUESTION_NUM)); + } + } + + @Override + @Transactional + public void addToLobby(String code, Long id) { + Player p = playerRepository.findById(id).orElse(null); + + if (p != null) { + MultiplayerSession ms = multiplayerSessionRepository.findByMultiplayerCode(code); + ms.addPlayer(p); + multiplayerSessionRepository.save(ms); + } + } + + @Override + @Transactional + public void changeScore(String code, Long id, int score) { + Player p = playerRepository.findById(id).orElse(null); + + if (p != null) { + MultiplayerSession ms = multiplayerSessionRepository.findByMultiplayerCode(code); + ms.getPlayerScores().put(p, score); + multiplayerSessionRepository.save(ms); + } + } + + @Override + public boolean existsCode(String code) { + return multiplayerSessionRepository.findByMultiplayerCode(code) != null; + } + + @Override + public List getQuestions(String code) { + if (!multiplayerSessionQuestions.containsKey(code)) { + return null; + } + return new ArrayList<>(multiplayerSessionQuestions.get(code)); + } +} diff --git a/src/main/java/com/uniovi/services/impl/PlayerServiceImpl.java b/src/main/java/com/uniovi/services/impl/PlayerServiceImpl.java index f4fa462d..7e000354 100644 --- a/src/main/java/com/uniovi/services/impl/PlayerServiceImpl.java +++ b/src/main/java/com/uniovi/services/impl/PlayerServiceImpl.java @@ -6,29 +6,34 @@ import com.uniovi.entities.Associations; import com.uniovi.entities.Player; import com.uniovi.repositories.PlayerRepository; -import com.uniovi.repositories.RoleRepository; +import com.uniovi.services.MultiplayerSessionService; import com.uniovi.services.PlayerService; import com.uniovi.services.RoleService; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import com.uniovi.entities.Role; +import java.security.SecureRandom; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Random; @Service public class PlayerServiceImpl implements PlayerService { - private PlayerRepository playerRepository; - private RoleService roleService; - private PasswordEncoder passwordEncoder; + private final PlayerRepository playerRepository; + private final RoleService roleService; + private final PasswordEncoder passwordEncoder; + private final MultiplayerSessionService multiplayerSessionService; + private final Random random = new SecureRandom(); - public PlayerServiceImpl(PlayerRepository playerRepository, RoleService roleService, PasswordEncoder passwordEncoder) { + public PlayerServiceImpl(PlayerRepository playerRepository, RoleService roleService, MultiplayerSessionService multiplayerSessionService,PasswordEncoder passwordEncoder) { this.playerRepository = playerRepository; this.roleService = roleService; this.passwordEncoder = passwordEncoder; + this.multiplayerSessionService = multiplayerSessionService; } @Override @@ -71,6 +76,13 @@ public List getUsers() { return l; } + @Override + public List getUsersByMultiplayerCode(int multiplayerCode) { + List l = new ArrayList<>(); + playerRepository.findAllByMultiplayerCode(multiplayerCode).forEach(l::add); + return l; + } + @Override public Optional getUser(Long id) { return playerRepository.findById(id); @@ -99,7 +111,6 @@ public List getUsersByRole(String role) { public void generateApiKey(Player player) { ApiKey apiKey = new ApiKey(); Associations.PlayerApiKey.addApiKey(player, apiKey); - System.out.println("Generated API key for " + player.getUsername() + ": " + apiKey.getKeyToken()); playerRepository.save(player); } @@ -132,8 +143,89 @@ public void updatePlayer(Long id, PlayerDto playerDto) { playerRepository.save(p); } + @Override + public boolean changeMultiplayerCode(Long id, String code) { + Optional player = playerRepository.findById(id); + if (player.isEmpty()) + return false; + + Player p = player.get(); + if(existsMultiplayerCode(code)){ + p.setMultiplayerCode(Integer.parseInt(code)); + playerRepository.save(p); + return true; + } + return false; + } + @Override + public String getScoreMultiplayerCode(Long id) { + Optional player = playerRepository.findById(id); + if (player.isEmpty()) + return ""; + + return player.get().getScoreMultiplayerCode(); + } + + @Override + public void setScoreMultiplayerCode(Long id, String score) { + Optional player = playerRepository.findById(id); + if (player.isEmpty()) + return; + + Player p =player.get(); + p.setScoreMultiplayerCode(score); + playerRepository.save(p); + } + /** + * A multiplayerCodeExists if there are any player + * with same multiplayerCode at the moment of the join + * */ + private boolean existsMultiplayerCode(String code){ + return ! multiplayerSessionService.getPlayersWithScores(Integer.parseInt(code)).isEmpty(); + } + + @Override + public int createMultiplayerGame(Long id){ + Optional player = playerRepository.findById(id); + if (player.isEmpty()) + return -1; + + Player p = player.get(); + int code = random.nextInt(10000); + p.setMultiplayerCode(code); + playerRepository.save(p); + return code; + } + + @Override + public void deleteMultiplayerCode(Long id){ + Optional player = playerRepository.findById(id); + if (player.isEmpty()) + return; + + Player p = player.get(); + p.setMultiplayerCode(null); + playerRepository.save(p); + } + @Override public void deletePlayer(Long id) { playerRepository.deleteById(id); } + + @Override + public Page getPlayersPage(Pageable pageable) { + return playerRepository.findAll(pageable); + } + + @Override + public void updatePassword(Player player, String password) { + player.setPassword(passwordEncoder.encode(password)); + playerRepository.save(player); + } + + @Override + public void savePlayer(Player player) { + playerRepository.save(player); + } } diff --git a/src/main/java/com/uniovi/services/impl/QuestionServiceImpl.java b/src/main/java/com/uniovi/services/impl/QuestionServiceImpl.java index f0ec9f7e..aed0ceb0 100644 --- a/src/main/java/com/uniovi/services/impl/QuestionServiceImpl.java +++ b/src/main/java/com/uniovi/services/impl/QuestionServiceImpl.java @@ -9,23 +9,24 @@ import com.uniovi.repositories.QuestionRepository; import com.uniovi.services.AnswerService; import com.uniovi.services.CategoryService; +import com.uniovi.services.QuestionGeneratorService; import com.uniovi.services.QuestionService; import jakarta.persistence.EntityManager; import jakarta.transaction.Transactional; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.Setter; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.querydsl.QPageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import java.io.IOException; import java.security.SecureRandom; -import java.sql.SQLException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.Random; -import org.springframework.data.domain.Pageable; @Service public class QuestionServiceImpl implements QuestionService { @@ -35,6 +36,9 @@ public class QuestionServiceImpl implements QuestionService { private final AnswerRepository answerRepository; private final EntityManager entityManager; + @Setter + private QuestionGeneratorService questionGeneratorService; + private final Random random = new SecureRandom(); public QuestionServiceImpl(QuestionRepository questionRepository, CategoryService categoryService, @@ -81,8 +85,7 @@ public Question addNewQuestion(QuestionDto question) { @Override public List getAllQuestions() { - List l = new ArrayList<>(questionRepository.findAll()); - return l; + return new ArrayList<>(questionRepository.findAll()); } @Override @@ -171,29 +174,9 @@ public void deleteQuestion(Long id) { } @Override - public List testQuestions(int num) { - List res = new ArrayList<>(); - Category c = new Category("Test category", "Test category"); - categoryService.addNewCategory(c); - for (int i = 0; i < num; i++) { - Question q = new Question(); - q.setStatement("Test question " + i); - q.setLanguage(LocaleContextHolder.getLocale().getLanguage()); - Associations.QuestionsCategory.addCategory(q, c); - List answers = new ArrayList<>(); - for (int j = 0; j < 4; j++) { - Answer a = new Answer(); - a.setText("Test answer " + j); - a.setCorrect(j == 0); - if(j==0) q.setCorrectAnswer(a); - answerService.addNewAnswer(a); - answers.add(a); - } - Associations.QuestionAnswers.addAnswer(q, answers); - addNewQuestion(q); - res.add(q); - } - return res; + public void deleteAllQuestions() throws IOException { + questionGeneratorService.resetGeneration(); + questionRepository.deleteAll(); } } diff --git a/src/main/java/com/uniovi/services/impl/RestApiServiceImpl.java b/src/main/java/com/uniovi/services/impl/RestApiServiceImpl.java index 3ff29db6..aa23abc4 100644 --- a/src/main/java/com/uniovi/services/impl/RestApiServiceImpl.java +++ b/src/main/java/com/uniovi/services/impl/RestApiServiceImpl.java @@ -59,6 +59,7 @@ public List getPlayers(Map params) { Optional found = playerService.getUser(Long.parseLong(params.get("id"))); found.ifPresent(players::add); } catch (NumberFormatException ignored) { + } } @@ -85,7 +86,7 @@ public List getPlayers(Map params) { if (!ranOtherParams) return playerService.getUsersByRole(params.get("role")); else - players.removeIf(p -> !p.getRoles().stream().anyMatch(r -> r.getName().equals(params.get("role")))); + players.removeIf(p -> p.getRoles().stream().noneMatch(r -> r.getName().equals(params.get("role")))); } return players.stream().toList(); diff --git a/src/main/java/com/uniovi/services/impl/RoleServiceImpl.java b/src/main/java/com/uniovi/services/impl/RoleServiceImpl.java index b10bbcc1..bec8464c 100644 --- a/src/main/java/com/uniovi/services/impl/RoleServiceImpl.java +++ b/src/main/java/com/uniovi/services/impl/RoleServiceImpl.java @@ -35,4 +35,11 @@ public Role addRole(RoleDto role) { public Role getRole(String name) { return roleRepository.findById(name).orElse(null); } + + @Override + public List getAllRoles() { + List roles = new ArrayList<>(); + roleRepository.findAll().forEach(roles::add); + return roles; + } } diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 6e483e24..d7689afa 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -11,4 +11,5 @@ spring.jpa.hibernate.ddl-auto=update server.ssl.key-store=/certs/keystore.p12 server.ssl.key-store-password=${KEYSTORE_PASSWORD} server.ssl.keyStoreType=PKCS12 -server.ssl.keyAlias=keystore \ No newline at end of file +server.ssl.keyAlias=keystore +server.ssl.enabled-protocols=TLSv1.2 \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b2dc761a..563c3468 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -13,3 +13,7 @@ springdoc.api-docs.path=/api-docs springdoc.swagger-ui.path=/api springdoc.swagger-ui.operationsSorter=method springdoc.packagesToScan=com.uniovi.controllers.api + +management.endpoint.metrics.enabled=true +management.endpoints.web.exposure.include=prometheus +management.endpoints.jmx.exposure.include=* diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 2b91abb1..d4b67675 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -13,6 +13,7 @@ navbar.toEnglish=InglĂ©s navbar.toSpanish=Español navbar.toFrench=FrancĂ©s navbar.currentLanguage=Español +navbar.section.admin=Panel de administraciĂłn # Buttons for non-authenticated users navbar.signup=RegĂ­strate @@ -22,6 +23,7 @@ navbar.login=Inicia sesiĂłn navbar.profile=Perfil navbar.logout=Cerrar sesiĂłn navbar.profile.apikey=Clave de la API +navbar.admin.zone=Zona de administraciĂłn # -------------------Statements for the footer.html file--------------------- footer.copyright=© ASW - Grupo 04 B @@ -37,6 +39,7 @@ error.error=Error: index.heading=WIQ index.subtitle=Responde las preguntas correctamente y GANA!!! index.button=JUGAR +index.multiplayer.button=JUGAR CON AMIGOS # -------------------Statements for the home.html file--------------------- home.heading=Bienvenido @@ -82,6 +85,30 @@ ranking.question.right=Respuestas correctas ranking.question.wrong=Respuestas incorrectas ranking.time=Tiempo +# -------------------Statements for the multiplayerGame.html file--------------------- +multi.text = ÂżAĂșn no tienes un cĂłdigo? Crea uno y compĂĄrtelo con tus amigos +multi.create = Crear +multi.placeholder= Introduce el cĂłdigo correcto +multi.label = Únete a una partida +multi.join = Unirse +multi.onlyNumber = Solo se permiten nĂșmeros +multi.copyCode= Copiar cĂłdigo +multi.info=Resultados para la partida: + +# -------------------Statements for the lobby.html file--------------------- +lobby.info =Jugadores unidos a la partida: +lobby.friends =Comparte el cĂłdigo de tu partida con tus amigos +lobby.start = Empezar + +# -------------------Statements for the lobby.html file--------------------- +multi.code= CĂłdigo de la partida +multi.results =Ver resultados + +# -------------------Statements for the multiFinished.html file--------------------- +multi.finished= Partida finalizada +multi.points = Puntuaciones +multi.menu = Ir a la pĂĄgina de inicio +multi.code.invalid = CĂłdigo de partida invĂĄlido # -------------------Statements for the apiHome.html file--------------------- api.doc.title=DocumentaciĂłn de la API api.doc.description=Esta es la documentaciĂłn de la API de WIQ. AquĂ­ puedes encontrar informaciĂłn sobre los recursos disponibles, los parĂĄmetros que aceptan y los ejemplos de uso. @@ -114,4 +141,35 @@ game.continue=Siguiente pregunta answer.correct=La respuesta correcta era: game.points=Puntos: game.currentQuestion=Pregunta: -game.finish=El juego ha terminado. Tu puntuaciĂłn ha sido: \ No newline at end of file +game.finish=El juego ha terminado. Tu puntuaciĂłn ha sido: + + +# -------------------Statements for the admin section--------------------- +admin.section.user.management=AdministraciĂłn de usuarios +admin.section.question.management=AdministraciĂłn de preguntas +role.label=Roles +user.details=Acciones +admin.user.delete=Eliminar usuario +admin.user.delete.title=Confirmar borrado de usuario +admin.user.delete.message=ÂżEstĂĄ seguro de que desea eliminar este usuario?\nTodos los datos asociados con esta cuenta se eliminarĂĄn.\nLa acciĂłn es irreversible. +admin.changepassword=Cambiar contraseña +admin.changeroles=Modificar roles +modal.password.title=Confirmar cambio de contraseña para +admin.password.change.input=Nueva contraseña +admin.roles.change=Confirmar cambio de roles para +modal.new.role=Nuevo rol +modal.close=Cerrar +modal.confirm=Confirmar +admin.questions.delete.title=Borrar todas las preguntas +admin.questions.delete=ÂżEstĂĄ seguro de que desea eliminar todas las preguntas?\nEsta acciĂłn es irreversible.\nSe generaran de nuevo segĂșn pase el tiempo. +admin.monitoring=MonitorizaciĂłn de la aplicaciĂłn + +# -------------------Statements for the page management--------------------- +page.first=Primera +page.last=Última + +# -------------------Statements for the instructions --------------------- +instructions.nav=Instrucciones +instructions.heading=Como jugar a WIQ +instructions.subtitle= ÂĄSigue estos pasos para jugar a WIQ! +instructions.text= WIQ es un juego de preguntas y respuestas en el que tendrĂĄs que responder correctamente a una serie de preguntas para ganar puntos. Sigue estos pasos para jugar:\n\n1. RegĂ­strate en la aplicaciĂłn si aĂșn no lo has hecho.\n2. Inicia sesiĂłn con tu usuario y contraseña.\n3. Pulsa en el botĂłn "Jugar" para comenzar una partida.\n4. Responde a las preguntas correctamente para ganar puntos.\n5. ÂĄDiviĂ©rtete y compite con tus amigos para ver quiĂ©n es el mejor!\n\nPor cada pregunta correcta, recibirĂĄs 10 puntos mĂĄs el tiempo restante para contestarla, es decir, si contestas una pregunta correctamente y te quedan 15 segundos, tu puntuaciĂłn de esa pregunta serĂĄ de 25!\n\nÂĄBuena suerte y que empiece el juego! \ No newline at end of file diff --git a/src/main/resources/messages_en.properties b/src/main/resources/messages_en.properties index 97f0f133..ca5f791c 100644 --- a/src/main/resources/messages_en.properties +++ b/src/main/resources/messages_en.properties @@ -13,6 +13,7 @@ navbar.toEnglish=English navbar.toSpanish=Spanish navbar.toFrench=French navbar.currentLanguage=English +navbar.section.admin=Administration Section # Buttons for non-authenticated users navbar.signup=Sign Up @@ -22,6 +23,7 @@ navbar.login=Log In navbar.profile=Profile navbar.logout=Log Out navbar.profile.apikey=API Key +navbar.admin.zone=Admin Zone # -------------------Statements for the footer.html file--------------------- footer.copyright=© ASW - Group 04 B @@ -37,6 +39,8 @@ error.error=Error: index.heading=WIQ index.subtitle=Answer the questions correctly and WIN!!! index.button=PLAY +index.multiplayer.button=PLAY WITH FRIENDS + # -------------------Statements for the home.html file--------------------- home.heading=Welcome @@ -83,6 +87,31 @@ ranking.question.right=Right answers ranking.question.wrong=Wrong answers ranking.time=Time +# -------------------Statements for the multiplayerGame.html file--------------------- +multi.text =Don't have a code yet? Create one and share it with your friends +multi.create = Create +multi.placeholder=Enter the correct code +multi.label=Join a game +multi.join = Join +multi.onlyNumber=Only numbers allowed +multi.copyCode= Copy code +multi.info=reults for the game: + +# -------------------Statements for the lobby.html file--------------------- +lobby.info =Players joining the game: +lobby.friends =Share your game code with your friends +lobby.start =Start + +# -------------------Statements for the lobby.html file--------------------- +multi.code= Game code +multi.results = See results + +# -------------------Statements for the multiFinished.html file--------------------- +multi.finished= Finished game +multi.points = Points +multi.menu =Go to home page +multi.code.invalid = Invalid game code + # -------------------Statements for the apiHome.html file--------------------- api.doc.title=API Documentation api.doc.description=This document describes the REST API endpoints. @@ -117,4 +146,34 @@ game.points=Points: game.currentQuestion=Question: game.finish=The game has finished. Your score is: +# -------------------Statements for the admin section--------------------- +admin.section.user.management=Users management +admin.section.question.management=Questions management +role.label=Roles +user.details=Details +admin.user.delete=Delete user +admin.user.delete.title=Confirm deleting user +admin.user.delete.message=Are you sure you want to delete this user?\nAll data associated with this account will be erased\nThis action cannot be undone +admin.changepassword=Change password +admin.changeroles=Modify roles +modal.password.title=Confirm password change for +admin.password.change.input=New password +admin.roles.change=Confirm role change for +modal.new.role=New role +modal.close=Close +modal.confirm=Save changes +admin.questions.delete.title=Delete all questions +admin.questions.delete=Are you sure you want to delete all questions?\nThis action cannot be undone.\nQuestions will generate again as time passes. +admin.monitoring=Monitoring + +# -------------------Statements for the page management--------------------- +page.first=First +page.last=Last + +# -------------------Statements for the instructions --------------------- +instructions.heading=How to Play WIQ +instructions.subtitle=Follow These Steps to Play WIQ! +instructions.text=WIQ is a quiz game where you'll need to correctly answer a series of questions to earn points. Follow these steps to play:\n\n1. Register on the app if you haven't done so already.\n2. Log in with your username and password.\n3. Click the "Play" button to start a game.\n4. Answer the questions correctly to win points.\n5. Have fun and compete with your friends to see who is the best!\n\nFor each correct answer, you will receive 10 points plus the remaining time to answer it. For example, if you answer a question correctly and have 15 seconds left, your score for that question will be 25!\n\nGood luck and let the game begin! +instructions.nav=Instructions + diff --git a/src/main/resources/messages_es.properties b/src/main/resources/messages_es.properties index 174dad48..75dfbcac 100644 --- a/src/main/resources/messages_es.properties +++ b/src/main/resources/messages_es.properties @@ -13,6 +13,7 @@ navbar.toEnglish=InglĂ©s navbar.toSpanish=Español navbar.toFrench=FrancĂ©s navbar.currentLanguage=Español +navbar.section.admin=Panel de administraciĂłn # Buttons for non-authenticated users navbar.signup=RegĂ­strate @@ -22,7 +23,7 @@ navbar.profile.apikey=Clave de la API # Buttons for authenticated users navbar.profile=Perfil navbar.logout=Cerrar sesiĂłn - +navbar.admin.zone=Zona de administraciĂłn # -------------------Statements for the footer.html file--------------------- footer.copyright=© ASW - Grupo 04 B @@ -38,6 +39,8 @@ error.error=Error: index.heading=WIQ index.subtitle=Responde las preguntas correctamente y GANA!!! index.button=JUGAR +index.multiplayer.button=JUGAR CON AMIGOS + # -------------------Statements for the home.html file--------------------- home.heading=Bienvenido @@ -84,6 +87,31 @@ ranking.question.right=Respuestas correctas ranking.question.wrong=Respuestas incorrectas ranking.time=Tiempo +# -------------------Statements for the multiplayerGame.html file--------------------- +multi.text = ÂżAĂșn no tienes un cĂłdigo? Crea uno y compĂĄrtelo con tus amigos +multi.create = Crear +multi.placeholder= Introduce el cĂłdigo correcto +multi.label = Únete a una partida +multi.join = Unirse +multi.onlyNumber = Solo se permiten nĂșmeros +multi.copyCode= Copiar cĂłdigo +multi.info=Resultados para la partida: + +# -------------------Statements for the lobby.html file--------------------- +lobby.info =Jugadores unidos a la partida: +lobby.friends =Comparte el cĂłdigo de tu partida con tus amigos +lobby.start =Empezar + +# -------------------Statements for the lobby.html file--------------------- +multi.code= CĂłdigo de la partida +multi.results =Ver resultados + +# -------------------Statements for the multiFinished.html file--------------------- +multi.finished= Partida finalizada +multi.points = Puntuaciones +multi.menu = Ir a la pĂĄgina de inicio +multi.code.invalid = CĂłdigo de partida invĂĄlido + # -------------------Statements for the apiHome.html file--------------------- api.doc.title=DocumentaciĂłn de la API api.doc.description=Esta es la documentaciĂłn de la API de WIQ. AquĂ­ puedes encontrar informaciĂłn sobre los recursos disponibles, los parĂĄmetros que aceptan y los ejemplos de uso. @@ -116,4 +144,31 @@ game.continue=Siguiente pregunta answer.correct=La respuesta correcta era: game.points=Puntos: game.currentQuestion=Pregunta: -game.finish=El juego ha terminado. Tu puntuaciĂłn ha sido: \ No newline at end of file +game.finish=El juego ha terminado. Tu puntuaciĂłn ha sido: + +# -------------------Statements for the admin section--------------------- +admin.section.user.management=AdministraciĂłn de usuarios +admin.section.question.management=AdministraciĂłn de preguntas +role.label=Roles +user.details=Acciones +admin.user.delete=Eliminar usuario +admin.user.delete.title=Confirmar borrado de usuario +admin.user.delete.message=ÂżEstĂĄ seguro de que desea eliminar este usuario?\nTodos los datos asociados con esta cuenta se eliminarĂĄn.\nLa acciĂłn es irreversible. +admin.changepassword=Cambiar contraseña +admin.changeroles=Modificar roles +modal.password.title=Confirmar cambio de contraseña para +admin.password.change.input=Nueva contraseña +admin.roles.change=Confirmar cambio de roles para +modal.new.role=Nuevo rol +modal.close=Cerrar +modal.confirm=Confirmar + +# -------------------Statements for the page management--------------------- +page.first=Primera +page.last=Última + +# -------------------Statements for the instructions --------------------- +instructions.nav=Instrucciones +instructions.heading=Como jugar a WIQ +instructions.subtitle= ÂĄSigue estos pasos para jugar a WIQ! +instructions.text= WIQ es un juego de preguntas y respuestas en el que tendrĂĄs que responder correctamente a una serie de preguntas para ganar puntos. Sigue estos pasos para jugar:\n\n1. RegĂ­strate en la aplicaciĂłn si aĂșn no lo has hecho.\n2. Inicia sesiĂłn con tu usuario y contraseña.\n3. Pulsa en el botĂłn "Jugar" para comenzar una partida.\n4. Responde a las preguntas correctamente para ganar puntos.\n5. ÂĄDiviĂ©rtete y compite con tus amigos para ver quiĂ©n es el mejor!\n\nPor cada pregunta correcta, recibirĂĄs 10 puntos mĂĄs el tiempo restante para contestarla, es decir, si contestas una pregunta correctamente y te quedan 15 segundos, tu puntuaciĂłn de esa pregunta serĂĄ de 25!\n\nÂĄBuena suerte y que empiece el juego! \ No newline at end of file diff --git a/src/main/resources/messages_fr.properties b/src/main/resources/messages_fr.properties index 1f5d2b22..40ce5dc2 100644 --- a/src/main/resources/messages_fr.properties +++ b/src/main/resources/messages_fr.properties @@ -12,6 +12,7 @@ navbar.toEnglish=Anglais navbar.toSpanish=Espagnol navbar.toFrench=Français navbar.currentLanguage=Français +navbar.section.admin=Espace administrateur navbar.signup=S'inscrire navbar.login=Se connecter @@ -20,7 +21,7 @@ navbar.profile.apikey=ClĂ© d'API # Buttons for authenticated users navbar.profile=Profil navbar.logout=Se dĂ©connecter - +navbar.admin.zone=Espace administrateur # -------------------Statements for the footer.html file--------------------- footer.copyright=© ASW - Groupe 04 B @@ -36,6 +37,7 @@ error.error=Erreur : index.heading=WIQ index.subtitle=RĂ©pondez aux questions correctement et GAGNEZ !!! index.button=JOUER +index.multiplayer.button=JOUEZ AVEC DES AMIS # -------------------Statements for the home.html file--------------------- home.heading=Bienvenue @@ -78,6 +80,31 @@ ranking.question.right=RĂ©ponses correctes ranking.question.wrong=RĂ©ponses incorrectes ranking.time=Temps +# -------------------Statements for the multiplayerGame.html file--------------------- +multi.text = Vous n'avez pas encore de code ? CrĂ©ez-en un et partagez-le avec vos amis. +multi.create = CrĂ©er +multi.placeholder=Entrez le bon code +multi.label=Rejoignez une partie +multi.join =Rejoindre +multi.onlyNumber=Seuls les chiffres sont autorisĂ©s +multi.copyCode= Copier le code +multi.info=rĂ©sultats du jeu: + +# -------------------Statements for the lobby.html file--------------------- +lobby.info =Joueurs rejoignant le jeu : +lobby.friends =Partagez votre code de jeu avec vos amis +lobby.start = Commencer + +# -------------------Statements for the lobby.html file--------------------- +multi.code=Code du jeu +multi.results=Voir les rĂ©sultats + +# -------------------Statements for the multiFinished.html file--------------------- +multi.finished= Jeu terminĂ© +multi.points = Points +multi.menu =aller Ă  la page d'accueil +multi.code.invalid = Code invalide + # -------------------Statements for the apiHome.html file--------------------- api.doc.title=Documentation de l'API api.doc.description=Ceci est la documentation de l'API de WIQ. Vous pouvez trouver ici des informations sur les ressources disponibles, les paramĂštres qu'elles acceptent et des exemples d'utilisation. @@ -112,3 +139,33 @@ game.points=Points: game.currentQuestion=Question: game.finish=Le jeu est terminĂ©. Votre score est : +# -------------------DĂ©clarations pour la section administrateur--------------------- +admin.section.user.management=Gestion des utilisateurs +admin.section.question.management=Gestion des questions +role.label=RĂŽles +user.details=Actions +admin.user.delete=Supprimer l'utilisateur +admin.user.delete.title=Confirmer la suppression de l'utilisateur +admin.user.delete.message=Êtes-vous sĂ»r de vouloir supprimer cet utilisateur ?\nToutes les donnĂ©es associĂ©es Ă  ce compte seront supprimĂ©es.\nL'action est irrĂ©versible. +admin.changepassword=Changer le mot de passe +admin.changeroles=Modifier les rĂŽles +modal.password.title=Confirmer le changement de mot de passe pour +admin.password.change.input=Nouveau mot de passe +admin.roles.change=Confirmer le changement de rĂŽles pour +modal.new.role=Nouveau rĂŽle +modal.close=Fermer +modal.confirm=Confirmer +admin.questions.delete.title=Confirmer la suppression de toutes les questions +admin.questions.delete=Vous ĂȘtes sur le point de supprimer toutes les questions. Êtes-vous sĂ»r de vouloir continuer ? +admin.monitoring=Surveillance + +# -------------------DĂ©clarations pour la gestion de la page--------------------- +page.first=PremiĂšre +page.last=DerniĂšre + +# -------------------Statements for the instructions --------------------- +instructions.heading=Comment jouer Ă  WIQ +instructions.subtitle=Suivez ces Ă©tapes pour jouer Ă  WIQ ! +instructions.text=WIQ est un jeu de quiz oĂč vous devez rĂ©pondre correctement Ă  une sĂ©rie de questions pour gagner des points. Suivez ces Ă©tapes pour jouer :\n\n1. Inscrivez-vous sur l'application si ce n'est pas dĂ©jĂ  fait.\n2. Connectez-vous avec votre nom d'utilisateur et votre mot de passe.\n3. Cliquez sur le bouton "Jouer" pour commencer une partie.\n4. RĂ©pondez correctement aux questions pour gagner des points.\n5. Amusez-vous et compĂ©tissez avec vos amis pour voir qui est le meilleur !\n\nPour chaque rĂ©ponse correcte, vous recevrez 10 points plus le temps restant pour rĂ©pondre. Par exemple, si vous rĂ©pondez correctement Ă  une question et qu'il vous reste 15 secondes, votre score pour cette question sera de 25 !\n\nBonne chance et que le jeu commence ! +instructions.nav=Instructions + diff --git a/src/main/resources/static/JSON/QuestionTemplates.json b/src/main/resources/static/JSON/QuestionTemplates.json new file mode 100644 index 00000000..a910f19b --- /dev/null +++ b/src/main/resources/static/JSON/QuestionTemplates.json @@ -0,0 +1,97 @@ +{ + "language_placeholder" : "[LANGUAGE]", + "question_placeholder" : "[QUESTION]", + "answer_placeholder" : "[ANSWER]", + "categories" : [ + { + "name" : "Geography", + "questions" : [ + { + "type" : "capital", + "statements" : [ + { + "language" : "es", + "statement" : "ÂżCuĂĄl es la capital de [QUESTION]?" + }, + { + "language" : "en", + "statement" : "What is the capital of [QUESTION]?" + }, + { + "language" : "fr", + "statement" : "Quelle est la capitale de [QUESTION]?" + } + ], + "question" : "countryLabel", + "answer" : "capitalLabel", + "sparqlQuery" : "select distinct ?country ?[QUESTION] ?capital ?[ANSWER] where {\n ?country wdt:P31 wd:Q6256 .\n ?capital wdt:P31 wd:Q5119 .\n ?country wdt:P36 ?capital .\n ?country rdfs:label ?[QUESTION] .\n ?capital rdfs:label ?[ANSWER] .\n FILTER(LANG(?[QUESTION])=\"[LANGUAGE]\" && LANG(?[ANSWER])=\"[LANGUAGE]\")\n }" + }, + { + "type" : "currency", + "statements" : [ + { + "language" : "es", + "statement" : "ÂżCuĂĄl es la moneda de [QUESTION]?" + }, + { + "language" : "en", + "statement" : "What is the currency of [QUESTION]?" + }, + { + "language" : "fr", + "statement" : "Quelle est la monnaie de [QUESTION]?" + } + ], + "question" : "countryLabel", + "answer" : "currencyLabel", + "sparqlQuery" : "select distinct ?country ?[QUESTION] ?currency ?[ANSWER] where {\n ?country wdt:P31 wd:Q6256 .\n ?currency wdt:P31 wd:Q8142 .\n ?country wdt:P38 ?currency .\n ?country rdfs:label ?[QUESTION] .\n ?currency rdfs:label ?[ANSWER] .\n FILTER NOT EXISTS {?country wdt:P31 wd:Q3024240} .\n FILTER(LANG(?[QUESTION])=\"[LANGUAGE]\" && LANG(?[ANSWER])=\"[LANGUAGE]\")\n }" + } + ] + }, + { + "name" : "Science", + "questions" : [ + { + "type" : "element", + "statements" : [ + { + "language" : "es", + "statement" : "ÂżCuĂĄl es el sĂ­mbolo quĂ­mico del [QUESTION]?" + }, + { + "language" : "en", + "statement" : "What is the chemical symbol of [QUESTION]?" + }, + { + "language" : "fr", + "statement" : "Quel est le symbole chimique du [QUESTION]?" + } + ], + "question" : "elementLabel", + "answer" : "symbol", + "sparqlQuery" : "select distinct ?element ?[QUESTION] ?[ANSWER] where {\n ?element wdt:P31 wd:Q11344 .\n ?element wdt:P246 ?[ANSWER] .\n ?element rdfs:label ?[QUESTION] .\n FILTER NOT EXISTS {?element wdt:P31 wd:Q1299291} .\n FILTER(LANG(?[QUESTION])=\"[LANGUAGE]\")\n }" + }, + { + "type" : "atomic_number", + "statements" : [ + { + "language" : "es", + "statement" : "ÂżCuĂĄl es el nĂșmero atĂłmico del [QUESTION]?" + }, + { + "language" : "en", + "statement" : "What is the atomic number of [QUESTION]?" + }, + { + "language" : "fr", + "statement" : "Quel est le numĂ©ro atomique du [QUESTION]?" + } + ], + "question" : "elementLabel", + "answer" : "atomicNumber", + "sparqlQuery" : "select distinct ?element ?[QUESTION] ?[ANSWER] where {\n ?element wdt:P31 wd:Q11344 .\n ?element wdt:P1086 ?[ANSWER] .\n ?element rdfs:label ?[QUESTION] .\n FILTER NOT EXISTS {?element wdt:P31 wd:Q1299291} .\n FILTER(LANG(?[QUESTION])=\"[LANGUAGE]\")\n }" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css new file mode 100644 index 00000000..db24eb8e --- /dev/null +++ b/src/main/resources/static/css/admin.css @@ -0,0 +1,26 @@ +.nav .nav-link { + color: white; + border-color: white; +} + +.nav .nav-item { + margin: 0 5px; + flex: 1; +} + +.nav .nav-link.active { + color: black !important; +} + +.nav-tabs { + border-bottom: 0px; +} + +.separator { + border-bottom: 1px solid white; + margin: 10px 0; +} + +.text-danger-light { + color: #ff5e5e; +} \ No newline at end of file diff --git a/src/main/resources/static/css/custom.css b/src/main/resources/static/css/custom.css index 37ab4183..18ebb97e 100644 --- a/src/main/resources/static/css/custom.css +++ b/src/main/resources/static/css/custom.css @@ -8,14 +8,6 @@ body { margin-bottom: 60px; color: #fff; } -footer { - position: absolute; - bottom: 0; - width: 100%; - height: 60px; - text-align:center; - line-height:60px -} .bg-primary { background-color: rgb(1, 85, 20) !important; @@ -62,4 +54,68 @@ footer { .nav .nav-link:hover { color: rgba(255, 255, 255, 0.7) !important; /* Cambia el color del texto del enlace cuando se pasa el mouse */ +} + +.prueba { + font-weight: bold; +} + +.button-container { + display: flex; + justify-content: space-between; +} + +.button-container a { + flex: 1; + margin: 0 5px; +} + +.modal { + color: black; +} + +.modal .btn.btn-primary { + background-color: #007bff; + border-color: #007bff; +} + +.btn-close { + box-sizing: content-box; + width: 1em; + height: 1em; + padding: .25em .25em; + color: #000; + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat; + border: 0; + border-radius: .25rem; + opacity: .5 +} + +.btn-close:hover { + color: #000; + text-decoration: none; + opacity: .75 +} + +.btn-close:focus { + outline: 0; + box-shadow: 0 0 0 .25rem rgba(13, 110, 253, .25); + opacity: 1 +} + +.btn-close.disabled, +.btn-close:disabled { + pointer-events: none; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + opacity: .25 +} + +.modal-body { + white-space: pre; +} + +.container, .container-fluid { + flex: 1 1 auto !important; } \ No newline at end of file diff --git a/src/main/resources/static/css/footer.css b/src/main/resources/static/css/footer.css index 77a89c6c..e9e67a72 100644 --- a/src/main/resources/static/css/footer.css +++ b/src/main/resources/static/css/footer.css @@ -1,14 +1,9 @@ /* Estilo del footer */ .footer { - position: absolute; bottom: 0; - min-height: 10%; width: 100%; - padding: 0; /* Eliminar relleno */ - text-align: center; /* AlineaciĂłn del texto */ - display: flex; /* Usar flexbox para centrar verticalmente */ - /*align-items: center; /* Centrar verticalmente */ - margin-top: 5%; + height: 60px; /* Set the fixed height of the footer here */ + line-height: 60px; /* Vertically center the text there */ background-color: transparent !important; /* Hace que el footer sea transparente */ } diff --git a/src/main/resources/static/css/multiplayer.css b/src/main/resources/static/css/multiplayer.css new file mode 100644 index 00000000..6715e189 --- /dev/null +++ b/src/main/resources/static/css/multiplayer.css @@ -0,0 +1,26 @@ +.display-5 { + margin-top: 3em; +} + +#lobbyCode, #label-code { + font-size: 3em; +} + +#lobbyInfo { + margin-top: 3em; +} + +#playerList { + list-style-type: none; + text-align: center; + padding: 0; +} + +#finishedGame { + font-size: 5em; + margin-bottom: 0.5em; +} + +#multiPoints { + margin-top: 3em; +} diff --git a/src/main/resources/static/css/nav.css b/src/main/resources/static/css/nav.css index 79c8ca3c..102f4e28 100644 --- a/src/main/resources/static/css/nav.css +++ b/src/main/resources/static/css/nav.css @@ -18,7 +18,7 @@ /* Estilo para los desplegables */ .dropdown-menu { color: #fff; - background-color: black; + background-color: rgb(19, 19, 19); border: 2px solid #fff; } diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css index 84d2796c..a28db0d5 100644 --- a/src/main/resources/static/css/style.css +++ b/src/main/resources/static/css/style.css @@ -6,4 +6,12 @@ #apiKeyDiv form { margin: 5% 20% 0 20%; +} + +html { + position: relative; + min-height: 100%; +} +body { + margin-bottom: 60px; /* Margin bottom by footer height */ } \ No newline at end of file diff --git a/src/main/resources/static/script/adminModals.js b/src/main/resources/static/script/adminModals.js new file mode 100644 index 00000000..aa468c2e --- /dev/null +++ b/src/main/resources/static/script/adminModals.js @@ -0,0 +1,112 @@ +function setupUserEvents() { + $("#deleteUserAdminModal").on('show.bs.modal', function (event) { + let button = $(event.relatedTarget); + let username = button.attr('data-bs-username'); + $(".modal-title b").text('"' + username + '"'); + $("#deleteModalConfirm").attr('data-bs-username', username); + }); + + $("#deleteModalConfirm").click(function () { + let username = $(this).attr('data-bs-username'); + $.ajax({ + url: "/player/admin/deleteUser", + type: "GET", + data: { + username: username + }, + success: function (data) { + $('#tab-content').load('/player/admin/userManagement'); + $("#deleteUserAdminModal").modal('hide'); + } + }); + }); + + $("#changePasswordAdminModal").on('show.bs.modal', function (event) { + let button = $(event.relatedTarget); + let username = button.attr('data-bs-username'); + $(".modal-title b").text('"' + username + '"'); + $("#changePasswordConfirm").attr('data-bs-username', username); + }); + + $("#changePasswordConfirm").click(function () { + let username = $(this).attr('data-bs-username'); + let newPass = $("#changePasswordInput").val(); + $.ajax({ + url: "/player/admin/changePassword", + type: "GET", + data: { + username: username, + password: newPass + }, + success: function (data) { + $('#tab-content').load('/player/admin/userManagement'); + $("#changePasswordAdminModal").modal('hide'); + } + }); + }); + + $("#changeRolesAdminModal").on('show.bs.modal', function (event) { + let button = $(event.relatedTarget); + let username = button.attr('data-bs-username'); + $(".modal-title b").text('"' + username + '"'); + $("#changeRolesConfirm").attr('data-bs-username', username); + $.ajax({ + url: "/player/admin/getRoles", + type: "GET", + data: { + username: username + }, + success: function (data) { + let roles = JSON.parse(data); + let rolesContainer = $("#rolesContainer"); + rolesContainer.empty(); + let i = 0; + for (const role in roles) { + let hasRole = roles[role]; + let div = $('
'); + let input = $(''); + let label = $(''); + div.append(input); + div.append(label); + rolesContainer.append(div); + i = i + 1; + } + }, + error: function (data) { + alert("Error: " + data); + } + }); + }); + + $("#changeRolesConfirm").click(function () { + let username = $(this).attr('data-bs-username'); + + let allRoles = $("#rolesContainer input"); + let roles = {}; + allRoles.each(function() { + roles[$(this).val()] = $(this).is(':checked'); + }); + let newRoleInput = $("#newRole").val(); + if (newRoleInput !== "") { + roles[newRoleInput] = true; + } + + let rolesString = JSON.stringify(roles); + + $.ajax({ + url: "/player/admin/changeRoles", + type: "GET", + data: { + username: username, + roles: rolesString + }, + success: function (data) { + $('#tab-content').load('/player/admin/userManagement'); + $("#changeRolesAdminModal").modal('hide'); + }, + error: function (data) { + alert("Error: " + data); + } + }); + }); +} \ No newline at end of file diff --git a/src/main/resources/static/script/questionManagement.js b/src/main/resources/static/script/questionManagement.js new file mode 100644 index 00000000..1d3280dd --- /dev/null +++ b/src/main/resources/static/script/questionManagement.js @@ -0,0 +1,38 @@ +function setupQuestionManagement() { + var editor; + $("#deleteQuestionsConfirm").on("click", function () { + $.ajax({ + url: "/player/admin/deleteAllQuestions", + type: "GET", + success: function () { + $('#tab-content').load('/player/admin/questionManagement'); + } + }); + }); + + $("#saveButton").on("click", function () { + $.ajax({ + url: "/player/admin/saveQuestions", + type: "GET", + data: { + json: JSON.stringify(editor.get()) + }, + contentType: "application/json" + }); + }); + + $.ajax({ + url: '/JSON/QuestionTemplates.json', + type: 'GET', + success: function (data) { + let json = data; + const element = document.getElementById('jsonEditorElement'); + const options = {} + editor = new JSONEditor(element, options) + editor.set(json) + }, + error: function (error) { + console.log(error); + } + }); +} \ No newline at end of file diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html index 66d23f12..e4ac7290 100644 --- a/src/main/resources/templates/error.html +++ b/src/main/resources/templates/error.html @@ -1,7 +1,7 @@ - + diff --git a/src/main/resources/templates/fragments/adminModals.html b/src/main/resources/templates/fragments/adminModals.html new file mode 100644 index 00000000..d5c19e74 --- /dev/null +++ b/src/main/resources/templates/fragments/adminModals.html @@ -0,0 +1,76 @@ + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/fragments/footer.html b/src/main/resources/templates/fragments/footer.html index 0579216d..bebd50d9 100644 --- a/src/main/resources/templates/fragments/footer.html +++ b/src/main/resources/templates/fragments/footer.html @@ -1,5 +1,5 @@ -