mirror of
https://github.com/MDeLuise/plant-it.git
synced 2026-02-04 02:59:58 -06:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26de7e2aa2 | ||
|
|
33e991b07f | ||
|
|
ecbcb55ed7 | ||
|
|
6d53b9f2fd | ||
|
|
58851ea2dc | ||
|
|
5d0d7ba9eb | ||
|
|
a9e049af7f | ||
|
|
f17e321467 | ||
|
|
cd596a769a | ||
|
|
231512311c | ||
|
|
ddcd8cedef | ||
|
|
fb56dd01a5 | ||
|
|
ed61b0c831 | ||
|
|
c9414b3d07 | ||
|
|
3a2ac6f103 | ||
|
|
4d61128914 | ||
|
|
9bffb690c7 | ||
|
|
d0b82b553f | ||
|
|
05e603d9e9 | ||
|
|
ba747463a5 | ||
|
|
17112a323c | ||
|
|
5dfd188ac3 | ||
|
|
5ce0a185ac | ||
|
|
5f390521a0 | ||
|
|
e730a6329d | ||
|
|
f772577885 | ||
|
|
30c86aad5b | ||
|
|
0728528325 | ||
|
|
35f614a2ea | ||
|
|
1ffb74863b | ||
|
|
32b5bd7915 | ||
|
|
304b9d6867 | ||
|
|
62ca830793 | ||
|
|
a22eea10f5 | ||
|
|
18863ed05a | ||
|
|
45bce224df | ||
|
|
8c9d4f893c | ||
|
|
8520004154 | ||
|
|
2a2f7e3f76 | ||
|
|
bd2b67b3de | ||
|
|
292ae00912 | ||
|
|
e2bb1559a2 | ||
|
|
0a646c8863 | ||
|
|
9c14e653a0 | ||
|
|
08429b74a9 | ||
|
|
c521dfd358 | ||
|
|
f0c150b414 | ||
|
|
48bb403f9a | ||
|
|
07225e9a25 | ||
|
|
ba0288984b | ||
|
|
76c7ccbd5a | ||
|
|
f050da117a | ||
|
|
34cc7adfdd | ||
|
|
9575cab33a | ||
|
|
73d0c32f82 | ||
|
|
f5cdadf13e | ||
|
|
e9ed987ed3 | ||
|
|
70ae9ce565 | ||
|
|
60175b7e43 | ||
|
|
df083674c1 | ||
|
|
6863027033 | ||
|
|
98ebfc6c44 | ||
|
|
b6f03a461e | ||
|
|
d59d3f354b | ||
|
|
4276a1cd0a | ||
|
|
73f1b43ad2 | ||
|
|
9aa75e0e7b | ||
|
|
435b8c179b | ||
|
|
682ca65d72 |
10
.github/DISCUSSION_TEMPLATE/question.yml
vendored
10
.github/DISCUSSION_TEMPLATE/question.yml
vendored
@ -7,11 +7,9 @@ body:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: (if applicable) Local environment
|
||||
label: Local environment
|
||||
description: If applicable, please provide additional information related to the environment you are using.
|
||||
placeholder: |
|
||||
1. backend version and app version
|
||||
2. content of docker-compose.yml file (remember to hide possible sensitive info)
|
||||
3. content of server.env file (remember to hide possible sensitive info)
|
||||
4. app log
|
||||
5. output of the docker compose log
|
||||
1. app version
|
||||
2. phone model
|
||||
3. phone OS version
|
||||
|
||||
45
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
45
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -37,14 +37,6 @@ body:
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: If applicable, add complete logs from the system logs.
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
@ -53,7 +45,7 @@ body:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
# Host details
|
||||
# App details
|
||||
- type: input
|
||||
id: plant-it-version
|
||||
attributes:
|
||||
@ -61,39 +53,10 @@ body:
|
||||
placeholder: v1.0.0
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: plant-it-setup-method
|
||||
attributes:
|
||||
label: Setup Method
|
||||
description: How did you installed Plant-it?
|
||||
options:
|
||||
- Docker
|
||||
- Non-docker
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
- type: input
|
||||
id: plant-it-setup-os
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: In which operating system does the server run?
|
||||
options:
|
||||
- Linux (Ubuntu, CentOS,...)
|
||||
- Windows
|
||||
- macOS
|
||||
- other (please specify in description)
|
||||
label: Phone Model and Operating System version
|
||||
description: In which operating system does the app run?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
# Client details
|
||||
- type: textarea
|
||||
id: client-details
|
||||
attributes:
|
||||
label: Client details
|
||||
description: Details about your browser and operating system used to access Plant-it.
|
||||
placeholder: |
|
||||
- OS: [e.g. Windows, macOS, iOS, Android]
|
||||
- Browser [e.g. Chrome, Firefox, Safari]
|
||||
- Browser Version [e.g. 101.4]
|
||||
- Android APK
|
||||
validations:
|
||||
required: false
|
||||
|
||||
14
.github/dependabot.yml
vendored
14
.github/dependabot.yml
vendored
@ -1,19 +1,5 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain dependencies for server
|
||||
- package-ecosystem: "maven"
|
||||
directory: "/backend"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:30"
|
||||
timezone: "Europe/Rome"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "Status: Review Needed"
|
||||
- "Type: Maintenance"
|
||||
- "Priority: Low"
|
||||
|
||||
# Maintain dependencies for app
|
||||
- package-ecosystem: "pub"
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
|
||||
51
.github/workflows/coverage.yml
vendored
51
.github/workflows/coverage.yml
vendored
@ -1,51 +0,0 @@
|
||||
name: Measure coverage
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
calculate_coverage:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
if: ${{ github.actor != 'dependabot[bot]' }}
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Maven Action
|
||||
if: ${{ github.actor != 'dependabot[bot]' }}
|
||||
uses: s4u/setup-maven-action@v1.2.1
|
||||
with:
|
||||
java-version: 21
|
||||
|
||||
- name: Run backend tests
|
||||
if: ${{ github.actor != 'dependabot[bot]' }}
|
||||
run: mvn clean install
|
||||
working-directory: ./backend
|
||||
|
||||
- name: Run Coverage
|
||||
if: ${{ github.actor != 'dependabot[bot]' }}
|
||||
run: mvn jacoco:report
|
||||
working-directory: ./backend
|
||||
|
||||
- name: JaCoCo Report to PR
|
||||
if: ${{ github.actor != 'dependabot[bot]' }}
|
||||
uses: Madrapps/jacoco-report@v1.6.1
|
||||
with:
|
||||
paths: ${{ github.workspace }}/backend/target/site/jacoco/jacoco.xml
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
min-coverage-overall: 40
|
||||
min-coverage-changed-files: 60
|
||||
title: 📝 Coverage Report For Server Service
|
||||
pass-emoji: 🟢
|
||||
fail-emoji: 🔴
|
||||
|
||||
- name: Get the Coverage info
|
||||
if: ${{ github.actor != 'dependabot[bot]' }}
|
||||
run: |
|
||||
echo "Total coverage ${{ steps.jacoco.outputs.coverage-overall }}"
|
||||
echo "Changed Files coverage ${{ steps.jacoco.outputs.coverage-changed-files }}"
|
||||
10
.github/workflows/create-app.yml
vendored
10
.github/workflows/create-app.yml
vendored
@ -8,7 +8,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: 3.24.3
|
||||
FLUTTER_VERSION: 3.32.8
|
||||
|
||||
jobs:
|
||||
create_apk:
|
||||
@ -26,14 +26,8 @@ jobs:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '21'
|
||||
|
||||
- name: Create apk File
|
||||
run: flutter pub get && flutter build apk --release
|
||||
run: flutter pub get && flutter build apk --release --dart-define=ENV=prod
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Decode keystore
|
||||
|
||||
22
.github/workflows/main.yml
vendored
22
.github/workflows/main.yml
vendored
@ -9,26 +9,6 @@ on:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Setup Maven Action
|
||||
uses: s4u/setup-maven-action@v1.2.1
|
||||
with:
|
||||
java-version: 21
|
||||
|
||||
- name: Build
|
||||
run: mvn clean install -DskipTests -Dcheckstyle.skip
|
||||
working-directory: ./backend
|
||||
|
||||
- name: Verify the checkstyle
|
||||
run: mvn checkstyle:check
|
||||
working-directory: ./backend
|
||||
|
||||
- name: Run the tests
|
||||
run: mvn test
|
||||
working-directory: ./backend
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@ -39,7 +19,7 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: 3.24.3
|
||||
flutter-version: 3.32.8
|
||||
|
||||
- name: Download Dependencies
|
||||
run: flutter pub get
|
||||
|
||||
129
.github/workflows/release.yml
vendored
129
.github/workflows/release.yml
vendored
@ -3,32 +3,13 @@ name: Automated Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]*.[0-9]*.[0-9]*'
|
||||
- '[0-9]*\.[0-9]*\.[0-9]*(-[a-zA-Z0-9]+)?'
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: 3.24.3
|
||||
FLUTTER_VERSION: 3.32.8
|
||||
DOCKER_NAME: plant-it-server
|
||||
|
||||
jobs:
|
||||
build_and_test_backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Setup Maven Action
|
||||
uses: s4u/setup-maven-action@v1.2.1
|
||||
with:
|
||||
java-version: 21
|
||||
|
||||
- name: Create JAR File
|
||||
run: mvn package
|
||||
working-directory: ./backend
|
||||
|
||||
- name: Save JAR File
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-jar
|
||||
retention-days: 1
|
||||
path: ./backend/target/*.jar
|
||||
|
||||
build_and_test_frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@ -54,7 +35,7 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Create Flutter Build Files
|
||||
run: flutter build web --release
|
||||
run: flutter build apk --release
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Save Flutter Build Files
|
||||
@ -64,59 +45,6 @@ jobs:
|
||||
retention-days: 1
|
||||
path: ./frontend/build
|
||||
|
||||
create_and_push_images:
|
||||
needs: [build_and_test_backend, build_and_test_frontend]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Load JAR File
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: backend-jar
|
||||
path: ./backend/target/
|
||||
|
||||
- name: Load Flutter Build Files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: ./frontend/build
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to the GitHub Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set lowercase actor
|
||||
run: echo "LOWERCASE_ACTOR=${GITHUB_REPOSITORY_OWNER@L}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./deployment/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_USERNAME }}/${{ env.DOCKER_NAME }}:${{ github.ref_name }}
|
||||
${{ vars.DOCKERHUB_USERNAME }}/${{ env.DOCKER_NAME }}:latest
|
||||
ghcr.io/${{ env.LOWERCASE_ACTOR }}/${{ env.DOCKER_NAME }}:${{ github.ref_name }}
|
||||
ghcr.io/${{ env.LOWERCASE_ACTOR }}/${{ env.DOCKER_NAME }}:latest
|
||||
|
||||
create_apk:
|
||||
needs: build_and_test_frontend
|
||||
runs-on: ubuntu-latest
|
||||
@ -133,12 +61,6 @@ jobs:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Create apk File
|
||||
run: flutter pub get && flutter build apk --release
|
||||
working-directory: ./frontend
|
||||
@ -167,7 +89,7 @@ jobs:
|
||||
|
||||
create_release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_and_test_backend, create_apk]
|
||||
needs: create_apk
|
||||
steps:
|
||||
- name: Load apk File
|
||||
uses: actions/download-artifact@v4
|
||||
@ -175,24 +97,6 @@ jobs:
|
||||
name: frontend-apk
|
||||
path: .
|
||||
|
||||
- name: Load backend jar
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: backend-jar
|
||||
path: .
|
||||
|
||||
- name: Rename backend jar to server.jar
|
||||
run: mv *.jar ./server.jar
|
||||
|
||||
- name: Load frontend files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: .
|
||||
|
||||
- name: Create client.tar.gz
|
||||
run: tar -czf client.tar.gz -C ./web .
|
||||
|
||||
- name: Create Draft Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
@ -201,21 +105,20 @@ jobs:
|
||||
body: |
|
||||
Hello, Plant-it community!
|
||||
|
||||
# Services version
|
||||
- server version: ``
|
||||
- app version: ``
|
||||
|
||||
# Highlights
|
||||
Welcome to the release v${{ github.ref_name }} of Plant-it.
|
||||
|
||||
# Support Plant-it
|
||||
If you find the project helpful, you can support Plant-it by buying me a coffee at https://www.buymeacoffee.com/mdeluise
|
||||
|
||||
https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExaW1xYWsxcHd1Zm85dnk0aGdmMzAzaTh4ZG14ZXUwcGFjdTdwNDEydCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/FkD6uqVhmJMd2/giphy.gif
|
||||
|
||||
# What's Changed
|
||||
## 📱 App
|
||||
-
|
||||
|
||||
## 🗄️ Server
|
||||
-
|
||||
|
||||
## 📓 Documentation
|
||||
-
|
||||
## 🚀 Features
|
||||
## 🌟 Enhancements
|
||||
## 🐛 Bug fixes
|
||||
## 🌐 Translations
|
||||
## 🚀 Features
|
||||
|
||||
# New Contributors
|
||||
* X made their first contribution in Y
|
||||
@ -226,7 +129,5 @@ jobs:
|
||||
append_body: true
|
||||
files: |
|
||||
app-release.apk
|
||||
server.jar
|
||||
client.tar.gz
|
||||
fail_on_unmatched_files: true
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
55
.github/workflows/stale.yml
vendored
55
.github/workflows/stale.yml
vendored
@ -1,29 +1,28 @@
|
||||
name: 'Close stale issues and PRs'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 6 * * *'
|
||||
workflow_dispatch:
|
||||
# name: 'Close stale issues and PRs'
|
||||
# on:
|
||||
# schedule:
|
||||
# - cron: '30 6 * * *'
|
||||
# workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
days-before-stale: 90
|
||||
days-before-close: 14
|
||||
exempt-issue-labels: 'Type: Feature Request'
|
||||
stale-issue-message: 'This issue has been marked as stale due to no activity in the last 90 days. It will be closed in 14 days if no further activity occurs.'
|
||||
stale-pr-message: 'This pull request has been marked as stale due to no activity in the last 90 days. It will be closed in 14 days if no further activity occurs.'
|
||||
close-issue-message: 'Closing this issue as it has been stale for 14 days.'
|
||||
close-pr-message: 'Closing this pull request as it has been stale for 14 days.'
|
||||
stale-issue-label: 'stale'
|
||||
stale-pr-label: 'stale'
|
||||
operations-per-run: 30
|
||||
remove-issue-stale-when-updated: true
|
||||
remove-pr-stale-when-updated: true
|
||||
exempt-issue-author: 'dependabot[bot]'
|
||||
exempt-pr-author: 'dependabot[bot]'
|
||||
# jobs:
|
||||
# stale:
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# issues: write
|
||||
# pull-requests: write
|
||||
# steps:
|
||||
# - uses: actions/stale@v9
|
||||
# with:
|
||||
# days-before-stale: 90
|
||||
# days-before-close: 14
|
||||
# stale-issue-message: 'This issue has been marked as stale due to no activity in the last 90 days. It will be closed in 14 days if no further activity occurs.'
|
||||
# stale-pr-message: 'This pull request has been marked as stale due to no activity in the last 90 days. It will be closed in 14 days if no further activity occurs.'
|
||||
# close-issue-message: 'Closing this issue as it has been stale for 14 days.'
|
||||
# close-pr-message: 'Closing this pull request as it has been stale for 14 days.'
|
||||
# operations-per-run: 30
|
||||
# remove-issue-stale-when-updated: true
|
||||
# remove-pr-stale-when-updated: true
|
||||
# exempt-issue-author: 'dependabot[bot]'
|
||||
# exempt-pr-author: 'dependabot[bot]'
|
||||
# exempt-issue-labels: 'Type: Feature Request,Stale Excerpt'
|
||||
# exempt-pr-labels: 'Stale Excerpt'
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@ -1,10 +1 @@
|
||||
*.DS_Store
|
||||
|
||||
online-resources/**/public
|
||||
online-resources/**/node_modules
|
||||
online-resources/**/package-lock.json
|
||||
online-resources/**/resources
|
||||
online-resources/documentation/venv
|
||||
|
||||
frontend/untranslated.txt
|
||||
|
||||
|
||||
26
.vscode/launch.json
vendored
Normal file
26
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Flutter (Development)",
|
||||
"program": "frontend/lib/main.dart",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"args": [
|
||||
"--dart-define=ENV=dev"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Flutter (Production)",
|
||||
"program": "frontend/lib/main.dart",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"args": [
|
||||
"--dart-define=ENV=prod"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
142
README.md
142
README.md
@ -1,114 +1,34 @@
|
||||
<p align="center">
|
||||
<img width="150px" src="images/plant-it-logo.png" title="Plant-it">
|
||||
<img src="images/logo-square.png" alt="Plant-it" style="width: 130px; height: 130px; border-radius: 15px"/>
|
||||
</p>
|
||||
<p align="center">Plant-it is a <b>gardening companion app.</b><br>Useful for keeping track of plant care, receiving notifications about when to water plants, uploading plant images, and more.</p>
|
||||
|
||||
<h1 align="center">Plant-it</h1>
|
||||
|
||||
<p align="center"><i><b>[Project under "active" development, some features may be unstable or change in the future. A first release version is planned to be packed soon].</b></i></p>
|
||||
<p align="center">Plant-it is a <b>self-hosted gardening companion app.</b><br>Useful for keeping track of plant care, receiving notifications about when to water plants, uploading plant images, and more.</p>
|
||||
|
||||
<p align="center"><a href="https://docs.plant-it.org/latest/">Explore the documentation</a></p>
|
||||
|
||||
<p align="center"><a href="https://github.com/MDeLuise/plant-it/#why">Why?</a> • <a href="https://github.com/MDeLuise/plant-it/#features-highlight">Features highlights</a> • <a href="https://github.com/MDeLuise/plant-it/#quickstart">Quickstart</a> • <a href="https://github.com/MDeLuise/plant-it/#support-the-project">Support</a> • <a href="https://github.com/MDeLuise/plant-it/#contribute">Contribute</a></p>
|
||||
<p align="center"><a href="https://github.com/MDeLuise/plant-it/#why">Why?</a> • <a href="https://github.com/MDeLuise/plant-it/#features-highlight">Features highlights</a> • <a href="https://github.com/MDeLuise/plant-it/#download">Download</a> • <a href="https://github.com/MDeLuise/plant-it/#support-the-project">Support</a> • <a href="https://github.com/MDeLuise/plant-it/#contribute">Contribute</a></p>
|
||||
|
||||
<p align="center">
|
||||
<img src="/images/banner.png" width="100%" />
|
||||
<img src="images/banner.png" width="100%" />
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> This project has reached a stable level of maturity, and as such, active development has slowed down. While pull requests and small bug fixes are still welcome, new feature requests will be considered with difficulty.
|
||||
|
||||
## Why?
|
||||
Plant-it is a gardening companion app that helps you take care of your plants.
|
||||
|
||||
It does not recommend you about which action to take, instead it is designed to log the activity you are doing.
|
||||
This is on purpose, I strongly believe that the only one in charge of knowing when to water your plants, when to fertilize them, etc. is you (with the help of multiple online sources).
|
||||
It does not recommend you about which action to take; instead, it is designed to log the activity you are doing. This is on purpose, as I strongly believe that the only one in charge of knowing when to water your plants, when to fertilize them, etc., is you (with the help of multiple online sources).
|
||||
|
||||
Plant-it helps you remember the last time you did a treatment of your plants, which plants you have, collects photos of your plants, and notifies you about the time passed since the last action on them.
|
||||
|
||||
|
||||
## Features highlight
|
||||
* Add existing plants or user created plants to your collection
|
||||
* Add existing plants or user-created plants to your collection
|
||||
* Log events like watering, fertilizing, biostimulating, etc. for your plants
|
||||
* View all the logged events, filtering by plant and event type
|
||||
* Upload photos of your plants
|
||||
* Set reminders for some actions on your plants (e.g. notify if not watered every 4 days)
|
||||
* Set reminders for some actions on your plants (e.g., notify if not watered every 4 days)
|
||||
|
||||
## Quickstart
|
||||
### Server
|
||||
Installing Plant-it is pretty straight forward, in order to do so follow these steps:
|
||||
## Download
|
||||
You can download the Plant-it Android app from the following sources:
|
||||
|
||||
* Create a folder where you want to place all Plant-it related files.
|
||||
* Inside that folder, create a file named `docker-compose.yml` with this content:
|
||||
```yaml
|
||||
name: plant-it
|
||||
services:
|
||||
server:
|
||||
image: msdeluise/plant-it-server:latest
|
||||
env_file: server.env
|
||||
depends_on:
|
||||
- db
|
||||
- cache
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- "./upload-dir:/upload-dir"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "3000:3000"
|
||||
|
||||
db:
|
||||
image: mysql:8.0
|
||||
restart: always
|
||||
env_file: server.env
|
||||
volumes:
|
||||
- "./db:/var/lib/mysql"
|
||||
|
||||
cache:
|
||||
image: redis:7.2.1
|
||||
restart: always
|
||||
```
|
||||
* Inside that folder, create a file named `server.env` with this content:
|
||||
```properties
|
||||
#
|
||||
# DB
|
||||
#
|
||||
MYSQL_HOST=db
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USERNAME=root
|
||||
MYSQL_PSW=root
|
||||
MYSQL_DATABASE=bootdb
|
||||
MYSQL_ROOT_PASSWORD=root
|
||||
|
||||
#
|
||||
# JWT
|
||||
#
|
||||
JWT_SECRET=putTheSecretHere
|
||||
JWT_EXP=1
|
||||
|
||||
#
|
||||
# Server config
|
||||
#
|
||||
USERS_LIMIT=-1
|
||||
UPLOAD_DIR=/upload-dir
|
||||
API_PORT=8080
|
||||
FLORACODEX_KEY=
|
||||
LOG_LEVEL=DEBUG
|
||||
ALLOWED_ORIGINS=*
|
||||
|
||||
#
|
||||
# Cache
|
||||
#
|
||||
CACHE_TTL=86400
|
||||
CACHE_HOST=cache
|
||||
CACHE_PORT=6379
|
||||
```
|
||||
* Run the docker compose file (`docker compose -f docker-compose.yml up -d`), then the service will be available at `localhost:3000`, while the REST API will be available at `localhost:8080/api` (`localhost:8080/api/swagger-ui/index.html` for the documentation of them).
|
||||
|
||||
<a href="https://docs.plant-it.org/latest/server-installation/#configuration">Take a look at the documentation</a> in order to understand the available configurations.
|
||||
|
||||
## App
|
||||
You can access the Plant-it service using the web app at `http://<server_ip>:3000`.
|
||||
|
||||
For Android users, the app is also available as an APK, which can be downloaded either from the GitHub releases assets or from F-Droid.
|
||||
|
||||
### Download
|
||||
- **GitHub Releases**: You can download the latest APK from the [GitHub releases page](https://github.com/MDeLuise/plant-it/releases/latest).
|
||||
<p align="center">
|
||||
<a href="https://github.com/MDeLuise/plant-it/releases/latest"><img src="https://raw.githubusercontent.com/Kunzisoft/Github-badge/main/get-it-on-github.png" alt="Get it on GitHub" height="60" style="max-width: 200px"></a>
|
||||
@ -119,9 +39,10 @@ For Android users, the app is also available as an APK, which can be downloaded
|
||||
<a href="https://f-droid.org/packages/com.github.mdeluise.plantit" rel="nofollow"><img src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Get_it_on_F-Droid_%28material_design%29.svg/2880px-Get_it_on_F-Droid_%28material_design%29.svg.png" alt="Get it on F-Droid" height=40 ></a>
|
||||
</p>
|
||||
|
||||
### Installation
|
||||
For detailed instructions on how to install and configure the app, please refer to the [installation documentation](https://docs.plant-it.org/latest/app-installation/).
|
||||
|
||||
- **Obtanium**: You can also download the app from [Obtanium](http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/MDeLuise/plant-it).
|
||||
<p align="center">
|
||||
<a href="http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/MDeLuise/plant-it" rel="nofollow"><img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtanium" height=40 ></a>
|
||||
</p>
|
||||
|
||||
## Support the project
|
||||
If you find this project helpful and would like to support it, consider [buying me a coffee](https://www.buymeacoffee.com/mdeluise). Your generosity helps keep this project alive and ensures its continued development and improvement.
|
||||
@ -130,30 +51,11 @@ If you find this project helpful and would like to support it, consider [buying
|
||||
</p>
|
||||
|
||||
## Contribute
|
||||
Feel free to contribute and help improve the repo.
|
||||
|
||||
### Contributing Translations to the Project
|
||||
We welcome contributions to help improve the Plant-it project! Whether you want to fix bugs, add new features, or enhance documentation, your input is valuable.
|
||||
|
||||
Please refer to the [Contributing Guidelines](contributing.md) for detailed instructions on how to contribute effectively. Thank you for your support!
|
||||
|
||||
### Translations
|
||||
|
||||
If you're interested in contributing transactions to enhance the app, you can get started by following the guide provided [here](https://github.com/MDeLuise/plant-it/discussions/148). Your support and contributions are greatly appreciated.
|
||||
| Language | Filename | Translation |
|
||||
|----------|----------|-------------|
|
||||
| English | app_en.arb | 100% |
|
||||
| Italian | app_it.arb | 100% |
|
||||
| German | app_de.arb | 100% |
|
||||
| Polish | app_pl.arb | 100% |
|
||||
| Chinese | app_zh.arb | 100% |
|
||||
| Russian | app_ru.arb | 91% |
|
||||
| Dutch Flemish | app_nl.arb | 91% |
|
||||
| French | app_fr.arb | 90% |
|
||||
| Danish | app_da.arb | 90% |
|
||||
| Portuguese | app_pt.arb | 89% |
|
||||
| Ukrainian | app_uk.arb | 87% |
|
||||
| Spanish Castilian | app_es.arb | 87% |
|
||||
|
||||
### Bug Report, Feature Request and Question
|
||||
You can submit any of this in the [issues](https://github.com/MDeLuise/plant-it/issues/new/choose) section of the repository. Choose the right template and then fill in the required info.
|
||||
|
||||
### Feature development
|
||||
Let's discuss first possible solutions for the development before start working on that, please open a [feature request issue](https://github.com/MDeLuise/plant-it/issues/new?assignees=&labels=Status:+Created,Type:+Feature+Request&projects=&template=feature_request.yml).
|
||||
|
||||
### How to contribute
|
||||
If you want to make some changes and test them locally <a href="https://docs.plant-it.org/latest/support/#contributing">take a look at the documentation</a>.
|
||||
|
||||
10
backend/.gitignore
vendored
10
backend/.gitignore
vendored
@ -1,10 +0,0 @@
|
||||
# target
|
||||
target/
|
||||
|
||||
# IDE specific files
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Other
|
||||
.DS_Store
|
||||
*.iml
|
||||
@ -1,96 +0,0 @@
|
||||
# Testing Guideline
|
||||
|
||||
### Tools in use
|
||||
|
||||
### JUnit 5 as the testing framework
|
||||
|
||||
[JUnit 5](https://junit.org/junit5/) is the most advanced testing library for Java developers.
|
||||
It provides advanced features for testing any layer.
|
||||
Here it’s used to create unit tests.
|
||||
|
||||
### AssertJ as the assertions library
|
||||
|
||||
[AssertJ](https://assertj.github.io/doc/) extends the assertions of the testing framework, providing a rich set of assertions and improving the test code readability.
|
||||
|
||||
#### Prefer using AssertJ assertions methods over JUnit 5 ones
|
||||
|
||||
The assertion methods on both sides will achieve the main goal: validate the actual to the expected result.
|
||||
The main difference is that AssertJ provides more assertions than `assertEquals` or `assertTrue`, it has a variety of methods to use for different objects.
|
||||
|
||||
### Naming
|
||||
|
||||
#### Test class name
|
||||
|
||||
The test classes must match the subject of the test, plus the suffix
|
||||
`Test`.
|
||||
Example: if the subject of the test is the `Limit` class, the test name will be `LimitTest`.
|
||||
|
||||
#### Test method name
|
||||
|
||||
All the test methods should describe the test intent in the following structure:
|
||||
|
||||
* fixed prefix `should`
|
||||
* action name
|
||||
* expected result optional for happy paths and mandatory for negative scenarios (corner cases)
|
||||
|
||||
Example for happy paths (positive scenario):
|
||||
|
||||
* `shouldCreatePageable`
|
||||
* `shouldCreateLimitWithEqualsRange`
|
||||
|
||||
Example for negative scenario (or corner-case):
|
||||
|
||||
* `shouldReturnErrorWhenMaxResultsIsNegative`
|
||||
|
||||
#### Test description
|
||||
|
||||
This project uses the `@DisplayName` annotation from JUnit 5
|
||||
|
||||
#### Structure
|
||||
|
||||
```
|
||||
class CalculatorTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should sum up correctly two numbers")
|
||||
void shouldSumUpCorrectly() {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Creation
|
||||
|
||||
#### Prefer soft assertions to hard assertions
|
||||
|
||||
Hard Assertions are the normal assertion method that will halt the test execution once the actual result is not matching the expected one.
|
||||
Soft Assertion is a mechanism to run a group of assertions that won’t halt the execution until all the assertions have been executed, showing an error summary when it happens.
|
||||
It’s more beneficial when we assert multiple attributes from an object.
|
||||
|
||||
This project uses the `SoftAssertions.assertSoftly()` method from the AssertJ library, and you can see a lot of already implemented examples in the available tests.
|
||||
The current usage is done by importing statically the `SoftAssertions` class, using directly the
|
||||
`assertSoftly()` method.
|
||||
The assertions are done by using the consumer name, followed by the `assertThat()` method, as the consumer name in the below example is `softly`:
|
||||
|
||||
```
|
||||
SoftAssertions softly = new SoftAssertions();
|
||||
softly.assertThat(pageable.size()).as("page size is correct").isEqualTo(20);
|
||||
softly.assertThat(pageable.mode()).as("default page mode is correct")
|
||||
.isEqualTo(Pageable.Mode.CURSOR_NEXT);
|
||||
softly.assertThat(pageable.cursor().getKeysetElement(2)).as("first keySetElement is correct")
|
||||
.isEqualTo("First");
|
||||
});
|
||||
```
|
||||
|
||||
Don't forget to use the `as()` method!
|
||||
It will be useful to fast identity which assertion is failing.
|
||||
|
||||
#### Use the AssertJ method for expected exceptions
|
||||
|
||||
Use one of the existing AssertJ methods to test any exception.
|
||||
Try to write the `assertThat` followed by the exception class name.
|
||||
Example:
|
||||
`assertThatIllegalArgumentException()`.
|
||||
|
||||
When you can not find a method with a specific exception, use the method
|
||||
`assertThatThrownBy()`.
|
||||
244
backend/pom.xml
244
backend/pom.xml
@ -1,244 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.0</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
<groupId>com.github.mdeluise</groupId>
|
||||
<artifactId>plant-it</artifactId>
|
||||
<version>0.11.0</version>
|
||||
<name>Plant it</name>
|
||||
<description>Gardening companion app</description>
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<start-class>com.github.mdeluise.plantit.Application</start-class>
|
||||
</properties>
|
||||
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.modelmapper</groupId>
|
||||
<artifactId>modelmapper</artifactId>
|
||||
<version>3.2.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.11.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
<version>2.18.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<version>9.1.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.liquibase</groupId>
|
||||
<artifactId>liquibase-core</artifactId>
|
||||
<version>4.30.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
<version>3.4.0</version>
|
||||
</dependency>
|
||||
<!--
|
||||
https://github.com/kstyrc/embedded-redis/issues/135#issuecomment-1825620600
|
||||
Previous dependency it.ozimov:embedded-redis:0.7.3 does not work on m1 mac
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>com.github.codemonstur</groupId>
|
||||
<artifactId>embedded-redis</artifactId>
|
||||
<version>1.4.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
<version>3.4.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
<version>3.4.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j-core</artifactId>
|
||||
<version>8.10.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.auth0</groupId>
|
||||
<artifactId>java-jwt</artifactId>
|
||||
<version>4.4.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.junit.vintage</groupId>
|
||||
<artifactId>junit-vintage-engine</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.cucumber</groupId>
|
||||
<artifactId>cucumber-java</artifactId>
|
||||
<version>7.9.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.cucumber</groupId>
|
||||
<artifactId>cucumber-spring</artifactId>
|
||||
<version>7.20.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.cucumber</groupId>
|
||||
<artifactId>cucumber-junit</artifactId>
|
||||
<version>7.10.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- https://stackoverflow.com/a/65190070/20088695 -->
|
||||
<dependency>
|
||||
<groupId>org.junit.vintage</groupId>
|
||||
<artifactId>junit-vintage-engine</artifactId>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.hamcrest</groupId>
|
||||
<artifactId>hamcrest-core</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>2.7.0</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>copyFiles</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-antrun-plugin</artifactId>
|
||||
<version>3.1.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>initialize</phase>
|
||||
<goals>
|
||||
<goal>run</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<tasks>
|
||||
<echo>Copying files...</echo>
|
||||
<copy file="${project.basedir}/src/main/resources/upload-dir/dummy-image.jpg"
|
||||
tofile="/tmp/plant-it/3531aae7-486f-4650-a4e3-047d3755a759.jpg"/>
|
||||
</tasks>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<configuration>
|
||||
<configLocation>${project.basedir}/src/main/resources/checkstyle.xml
|
||||
</configLocation>
|
||||
<consoleOutput>true</consoleOutput>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>validate</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>10.20.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>0.8.12</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>prepare-package</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@ -1,12 +0,0 @@
|
||||
package com.github.mdeluise.plantit;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class Application {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Application.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,135 +0,0 @@
|
||||
package com.github.mdeluise.plantit;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.http.HttpClient;
|
||||
|
||||
import com.github.mdeluise.plantit.notification.otp.OtpService;
|
||||
import com.github.mdeluise.plantit.notification.password.TemporaryPasswordService;
|
||||
import com.github.mdeluise.plantit.reminder.ReminderDispatcher;
|
||||
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||
import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;
|
||||
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
|
||||
import io.swagger.v3.oas.annotations.info.Contact;
|
||||
import io.swagger.v3.oas.annotations.info.Info;
|
||||
import io.swagger.v3.oas.annotations.info.License;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityScheme;
|
||||
import io.swagger.v3.oas.annotations.servers.Server;
|
||||
import io.swagger.v3.oas.annotations.servers.ServerVariable;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.modelmapper.ModelMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import redis.embedded.RedisServer;
|
||||
|
||||
@Configuration
|
||||
@OpenAPIDefinition(
|
||||
info = @Info(
|
||||
title = "Plant-it REST API", version = "1.0",
|
||||
description = "<h1>Introduction</h1>" + "<p>Plant-it is a self-hosted, open " +
|
||||
"source, gardening companion app.</p>",
|
||||
license = @License(name = "GPL 3.0", url = "https://www.gnu.org/licenses/gpl-3.0.en.html"),
|
||||
contact = @Contact(name = "GitHub page", url = "https://github.com/MDeLuise/plant-it")
|
||||
), security = {@SecurityRequirement(name = "bearerAuth")}, servers = {
|
||||
@Server(description = "Localhost", url = "/api"),
|
||||
@Server(
|
||||
description = "Custom",
|
||||
url = "{protocol}://{host}:{port}/{basePath}",
|
||||
variables = {
|
||||
@ServerVariable(name = "protocol", defaultValue = "http", allowableValues = {"http", "https"}),
|
||||
@ServerVariable(name = "host", defaultValue = "localhost"),
|
||||
@ServerVariable(name = "port", defaultValue = "8085"),
|
||||
@ServerVariable(name = "basePath", defaultValue = "api")
|
||||
})
|
||||
}
|
||||
)
|
||||
@SecurityScheme(
|
||||
name = "bearerAuth", type = SecuritySchemeType.HTTP, bearerFormat = "JWT", scheme = "bearer",
|
||||
in = SecuritySchemeIn.HEADER
|
||||
)
|
||||
@EnableScheduling
|
||||
@EnableMethodSecurity
|
||||
@EnableCaching
|
||||
public class ApplicationConfig {
|
||||
private final OtpService otpService;
|
||||
private final TemporaryPasswordService temporaryPasswordService;
|
||||
private final ReminderDispatcher reminderDispatcher;
|
||||
private RedisServer redisServer;
|
||||
private final Logger logger = LoggerFactory.getLogger(ApplicationConfig.class);
|
||||
|
||||
|
||||
@Autowired
|
||||
public ApplicationConfig(OtpService otpService, TemporaryPasswordService temporaryPasswordService,
|
||||
ReminderDispatcher reminderDispatcher) {
|
||||
this.otpService = otpService;
|
||||
this.temporaryPasswordService = temporaryPasswordService;
|
||||
this.reminderDispatcher = reminderDispatcher;
|
||||
}
|
||||
|
||||
|
||||
@Bean
|
||||
public ModelMapper modelMapper() {
|
||||
return new ModelMapper();
|
||||
}
|
||||
|
||||
|
||||
@Bean
|
||||
@Profile(value = "dev")
|
||||
public CommandLineRunner initEmbeddedCache(@Value("${spring.data.redis.port}") int port) {
|
||||
return args -> {
|
||||
logger.debug("Starting embedded redis...");
|
||||
redisServer = new RedisServer(port);
|
||||
redisServer.start();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@PreDestroy
|
||||
public void stopRedisServer() throws IOException {
|
||||
if (redisServer != null) {
|
||||
logger.debug("Stopping embedded redis...");
|
||||
try {
|
||||
redisServer.stop();
|
||||
} catch (IOException e) {
|
||||
logger.error("Error while stopping embedded redis instance", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Bean
|
||||
public HttpClient httpClient() {
|
||||
return HttpClient.newHttpClient();
|
||||
}
|
||||
|
||||
|
||||
@CacheEvict(allEntries = true, cacheNames = {"latest-version"})
|
||||
@Scheduled(fixedDelay = 86400000) // 1 day
|
||||
public void cacheEvict() {
|
||||
}
|
||||
|
||||
|
||||
@Scheduled(fixedDelay = 28800000) // 8 hours
|
||||
public void removeExpired() {
|
||||
otpService.removeExpired();
|
||||
temporaryPasswordService.removeExpired();
|
||||
}
|
||||
|
||||
|
||||
@Scheduled(cron = "${reminders.notify_check}")
|
||||
public void dispatchReminders() {
|
||||
reminderDispatcher.dispatch();
|
||||
}
|
||||
}
|
||||
@ -1,173 +0,0 @@
|
||||
package com.github.mdeluise.plantit.authentication;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import com.github.mdeluise.plantit.authentication.payload.request.LoginRequest;
|
||||
import com.github.mdeluise.plantit.authentication.payload.request.SignupRequest;
|
||||
import com.github.mdeluise.plantit.authentication.payload.response.UserInfoResponse;
|
||||
import com.github.mdeluise.plantit.common.MessageResponse;
|
||||
import com.github.mdeluise.plantit.exception.MaximumNumberOfUsersReachedExceptions;
|
||||
import com.github.mdeluise.plantit.notification.email.EmailException;
|
||||
import com.github.mdeluise.plantit.notification.email.EmailService;
|
||||
import com.github.mdeluise.plantit.notification.otp.OtpService;
|
||||
import com.github.mdeluise.plantit.notification.password.TemporaryPasswordService;
|
||||
import com.github.mdeluise.plantit.security.jwt.JwtTokenInfo;
|
||||
import com.github.mdeluise.plantit.security.jwt.JwtWebUtil;
|
||||
import com.github.mdeluise.plantit.security.services.UserDetailsImpl;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/authentication")
|
||||
@Tag(name = "Authentication", description = "Endpoints for authentication")
|
||||
public class AuthController {
|
||||
private final AuthenticationManager authManager;
|
||||
private final JwtWebUtil jwtWebUtil;
|
||||
private final UserService userService;
|
||||
private final int maxAllowedUsers;
|
||||
private final EmailService emailService;
|
||||
private final OtpService otpService;
|
||||
private final TemporaryPasswordService temporaryPasswordService;
|
||||
private final Logger logger = LoggerFactory.getLogger(AuthController.class);
|
||||
|
||||
|
||||
@Autowired
|
||||
@SuppressWarnings("ParameterNumber")
|
||||
public AuthController(AuthenticationManager authManager, JwtWebUtil jwtWebUtil, UserService userService,
|
||||
@Value("${users.max}") int maxAllowedUsers, EmailService emailService, OtpService otpService,
|
||||
TemporaryPasswordService temporaryPasswordService) {
|
||||
this.authManager = authManager;
|
||||
this.jwtWebUtil = jwtWebUtil;
|
||||
this.userService = userService;
|
||||
this.maxAllowedUsers = maxAllowedUsers;
|
||||
this.emailService = emailService;
|
||||
this.otpService = otpService;
|
||||
this.temporaryPasswordService = temporaryPasswordService;
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/login")
|
||||
@Operation(
|
||||
summary = "Username and password login", description = "Login using username and password."
|
||||
)
|
||||
@SecurityRequirements()
|
||||
public ResponseEntity<UserInfoResponse> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
|
||||
final Authentication authentication = authManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(loginRequest.username(), loginRequest.password()));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
final User user = userService.get(loginRequest.username());
|
||||
final UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
|
||||
final JwtTokenInfo jwtCookie = jwtWebUtil.generateJwt(userDetails);
|
||||
final UserInfoResponse response =
|
||||
new UserInfoResponse(userDetails.getId(), userDetails.getUsername(), user.getEmail(), user.getLastLogin(),
|
||||
jwtCookie
|
||||
);
|
||||
user.setLastLogin(new Date());
|
||||
userService.updateInternal(user.getId(), user);
|
||||
return ResponseEntity.ok().body(response);
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/signup")
|
||||
@Operation(
|
||||
summary = "Signup", description = "Create a new account."
|
||||
)
|
||||
@SecurityRequirements()
|
||||
@SuppressWarnings("ReturnCount") // FIXME
|
||||
public ResponseEntity<MessageResponse> registerUser(@Valid @RequestBody SignupRequest signUpRequest)
|
||||
throws EmailException {
|
||||
if (maxAllowedUsers > 0 && userService.count() >= maxAllowedUsers) {
|
||||
throw new MaximumNumberOfUsersReachedExceptions();
|
||||
}
|
||||
if (userService.existsByUsername(signUpRequest.username())) {
|
||||
return ResponseEntity.badRequest().body(new MessageResponse("Error: Username is already taken!"));
|
||||
}
|
||||
if (userService.existsByEmail(signUpRequest.email())) {
|
||||
return ResponseEntity.badRequest().body(new MessageResponse("Error: Email is already used!"));
|
||||
}
|
||||
|
||||
if (emailService.isEnabled()) {
|
||||
emailService.sendOtpMessage(signUpRequest.username(), signUpRequest.email());
|
||||
return ResponseEntity.accepted().body(new MessageResponse("Signup request pending verification"));
|
||||
}
|
||||
|
||||
userService.save(signUpRequest.username(), signUpRequest.password(), signUpRequest.email());
|
||||
return ResponseEntity.ok(new MessageResponse("User registered successfully."));
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/signup/otp/{otpCode}")
|
||||
@Operation(
|
||||
summary = "Signup with OTP code", description = "Create a new account using a provided OTP code."
|
||||
)
|
||||
@SecurityRequirements()
|
||||
public ResponseEntity<MessageResponse> registerUserWithOTP(@Valid @RequestBody SignupRequest signUpRequest,
|
||||
@PathVariable String otpCode) {
|
||||
if (maxAllowedUsers > 0 && userService.count() >= maxAllowedUsers) {
|
||||
throw new MaximumNumberOfUsersReachedExceptions();
|
||||
}
|
||||
if (userService.existsByUsername(signUpRequest.username())) {
|
||||
return ResponseEntity.badRequest().body(new MessageResponse("Error: Username is already taken!"));
|
||||
}
|
||||
|
||||
if (otpService.checkExistenceAndExpirationThenRemove(otpCode)) {
|
||||
userService.save(signUpRequest.username(), signUpRequest.password(), signUpRequest.email());
|
||||
return ResponseEntity.ok(new MessageResponse("User registered successfully."));
|
||||
}
|
||||
final String errorMessage =
|
||||
"Your OTP code is either invalid or has expired. Please ensure that you have entered the correct code and" +
|
||||
" try again. If you believe this is an error, you can request a new OTP code by initiating the signup" +
|
||||
" process again";
|
||||
return ResponseEntity.badRequest().body(new MessageResponse(errorMessage));
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/refresh")
|
||||
@Operation(
|
||||
summary = "Refresh the authentication token", description = "Refresh the given authentication token."
|
||||
)
|
||||
public ResponseEntity<JwtTokenInfo> refreshAuthToken(HttpServletRequest httpServletRequest) {
|
||||
final String expiredToken = jwtWebUtil.getJwtTokenFromRequest(httpServletRequest);
|
||||
return ResponseEntity.ok().body(jwtWebUtil.refreshToken(expiredToken));
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/password/reset/{username}")
|
||||
@Operation(
|
||||
summary = "Initiates the password reset process",
|
||||
description = "Start the process of resetting the user password"
|
||||
)
|
||||
public ResponseEntity<String> resetPassword(@PathVariable String username) throws EmailException {
|
||||
if (!emailService.isEnabled()) {
|
||||
final String temporaryPassword = temporaryPasswordService.generateNew(username);
|
||||
logger.info("Temporary password for user {} is {}. Login with this temporary password.", username,
|
||||
temporaryPassword
|
||||
);
|
||||
return ResponseEntity.ok(
|
||||
"Password reset instructions have been printed into the server log. Contact the administrator in " +
|
||||
"order to get it if needed.");
|
||||
}
|
||||
final User user = userService.get(username);
|
||||
emailService.sendTemporaryPasswordMessage(username, user.getEmail());
|
||||
return ResponseEntity.ok("Password reset instructions have been sent to your email address.");
|
||||
}
|
||||
}
|
||||
@ -1,232 +0,0 @@
|
||||
package com.github.mdeluise.plantit.authentication;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.github.mdeluise.plantit.botanicalinfo.BotanicalInfo;
|
||||
import com.github.mdeluise.plantit.diary.Diary;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.config.AbstractNotificationDispatcherConfig;
|
||||
import com.github.mdeluise.plantit.plant.Plant;
|
||||
import com.github.mdeluise.plantit.security.apikey.ApiKey;
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.CollectionTable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
|
||||
@Entity(name = "user")
|
||||
@Table(name = "application_users")
|
||||
public class User implements Serializable {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
private Long id;
|
||||
@Column(unique = true)
|
||||
@NotEmpty
|
||||
@Length(min = 3, max = 20)
|
||||
private String username;
|
||||
@NotEmpty
|
||||
@Length(min = 8, max = 120)
|
||||
@JsonProperty
|
||||
private String password;
|
||||
@Column(unique = true)
|
||||
@NotEmpty
|
||||
@Length(min = 5, max = 70)
|
||||
private String email;
|
||||
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
|
||||
private Set<ApiKey> apiKeys = new HashSet<>();
|
||||
@OneToMany(mappedBy = "owner", cascade = CascadeType.ALL)
|
||||
private Set<Diary> diaries = new HashSet<>();
|
||||
@OneToMany(mappedBy = "owner", cascade = CascadeType.ALL)
|
||||
private Set<Plant> plants = new HashSet<>();
|
||||
@OneToMany(mappedBy = "creator")
|
||||
private Set<BotanicalInfo> botanicalInfos = new HashSet<>();
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@Enumerated(EnumType.STRING)
|
||||
@CollectionTable(
|
||||
name = "notification_dispatchers",
|
||||
joinColumns = @JoinColumn(name = "user_id")
|
||||
)
|
||||
@Column(name = "dispatcher_name")
|
||||
private Set<NotificationDispatcherName> notificationDispatchers = new HashSet<>();
|
||||
private Date lastLogin;
|
||||
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
|
||||
private Set<AbstractNotificationDispatcherConfig> configs = new HashSet<>();
|
||||
|
||||
|
||||
public User(Long id, String username, String password) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
|
||||
public User() {
|
||||
}
|
||||
|
||||
|
||||
@JsonIgnore
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
public Set<ApiKey> getApiKeys() {
|
||||
return apiKeys;
|
||||
}
|
||||
|
||||
|
||||
public void setApiKeys(Set<ApiKey> apiKeys) {
|
||||
this.apiKeys = apiKeys;
|
||||
}
|
||||
|
||||
|
||||
public void addApiKey(ApiKey apiKey) {
|
||||
apiKeys.add(apiKey);
|
||||
}
|
||||
|
||||
|
||||
public void removeApiKey(ApiKey apiKey) {
|
||||
apiKeys.remove(apiKey);
|
||||
}
|
||||
|
||||
|
||||
public Set<Diary> getLogs() {
|
||||
return diaries;
|
||||
}
|
||||
|
||||
|
||||
public void setLogs(Set<Diary> logs) {
|
||||
this.diaries = logs;
|
||||
}
|
||||
|
||||
|
||||
public Set<Plant> getPlants() {
|
||||
return plants;
|
||||
}
|
||||
|
||||
|
||||
public void setPlants(Set<Plant> plants) {
|
||||
this.plants = plants;
|
||||
}
|
||||
|
||||
|
||||
public Set<BotanicalInfo> getBotanicalInfos() {
|
||||
return botanicalInfos;
|
||||
}
|
||||
|
||||
|
||||
public void setBotanicalInfos(Set<BotanicalInfo> botanicalInfos) {
|
||||
this.botanicalInfos = botanicalInfos;
|
||||
}
|
||||
|
||||
|
||||
public Set<Diary> getDiaries() {
|
||||
return diaries;
|
||||
}
|
||||
|
||||
|
||||
public void setDiaries(Set<Diary> diaries) {
|
||||
this.diaries = diaries;
|
||||
}
|
||||
|
||||
|
||||
public Set<NotificationDispatcherName> getNotificationDispatchers() {
|
||||
return notificationDispatchers;
|
||||
}
|
||||
|
||||
|
||||
public void setNotificationDispatchers(Set<NotificationDispatcherName> notificationDispatchers) {
|
||||
this.notificationDispatchers = notificationDispatchers;
|
||||
}
|
||||
|
||||
|
||||
public Set<AbstractNotificationDispatcherConfig> getConfigs() {
|
||||
return configs;
|
||||
}
|
||||
|
||||
|
||||
public void setConfigs(Set<AbstractNotificationDispatcherConfig> configs) {
|
||||
this.configs = configs;
|
||||
}
|
||||
|
||||
|
||||
public Date getLastLogin() {
|
||||
return lastLogin;
|
||||
}
|
||||
|
||||
|
||||
public void setLastLogin(Date lastLogin) {
|
||||
this.lastLogin = lastLogin;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
User user = (User) o;
|
||||
return Objects.equals(id, user.id);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(id);
|
||||
}
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
package com.github.mdeluise.plantit.authentication;
|
||||
|
||||
import javax.naming.AuthenticationException;
|
||||
|
||||
import com.github.mdeluise.plantit.authentication.payload.request.ChangeEmailRequest;
|
||||
import com.github.mdeluise.plantit.authentication.payload.request.ChangePasswordRequest;
|
||||
import com.github.mdeluise.plantit.authentication.payload.request.ChangeUsernameRequest;
|
||||
import com.github.mdeluise.plantit.common.AuthenticatedUserService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/user")
|
||||
@Tag(name = "User", description = "Endpoints for CRUD operations on users")
|
||||
public class UserController {
|
||||
private final UserService userService;
|
||||
private final AuthenticatedUserService authenticatedUserService;
|
||||
private final UserDTOConverter userDtoConverter;
|
||||
|
||||
|
||||
@Autowired
|
||||
public UserController(UserService userService, AuthenticatedUserService authenticatedUserService,
|
||||
UserDTOConverter userDtoConverter) {
|
||||
this.userService = userService;
|
||||
this.authenticatedUserService = authenticatedUserService;
|
||||
this.userDtoConverter = userDtoConverter;
|
||||
}
|
||||
|
||||
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "Get the User details", description = "Get the details of the authenticated User."
|
||||
)
|
||||
public ResponseEntity<UserDTO> getDetail() {
|
||||
final User result = authenticatedUserService.getAuthenticatedUser();
|
||||
return new ResponseEntity<>(userDtoConverter.convertToDTO(result), HttpStatus.OK);
|
||||
}
|
||||
|
||||
|
||||
@PutMapping("/_password")
|
||||
@Operation(
|
||||
summary = "Update a User's password", description = "Update the password of the authenticated User"
|
||||
)
|
||||
public ResponseEntity<String> updatePassword(@RequestBody ChangePasswordRequest changePasswordRequest)
|
||||
throws AuthenticationException {
|
||||
final Long idOfUserToUpdate = authenticatedUserService.getAuthenticatedUser().getId();
|
||||
userService.updatePassword(idOfUserToUpdate, changePasswordRequest.currentPassword(),
|
||||
changePasswordRequest.newPassword()
|
||||
);
|
||||
return ResponseEntity.ok("updated");
|
||||
}
|
||||
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(
|
||||
summary = "Delete a single User", description = "Delete the authenticated User."
|
||||
)
|
||||
public void remove() {
|
||||
final Long toRemove = authenticatedUserService.getAuthenticatedUser().getId();
|
||||
userService.remove(toRemove);
|
||||
}
|
||||
|
||||
|
||||
@PutMapping("/_username")
|
||||
@Operation(
|
||||
summary = "Update a user's username", description = "Update the username of the authenticated User"
|
||||
)
|
||||
public ResponseEntity<String> updateUsername(@RequestBody ChangeUsernameRequest changeUsernameRequest)
|
||||
throws AuthenticationException {
|
||||
final Long idOfUserToUpdate = authenticatedUserService.getAuthenticatedUser().getId();
|
||||
userService.updateUsername(
|
||||
idOfUserToUpdate, changeUsernameRequest.password(), changeUsernameRequest.newUsername());
|
||||
return ResponseEntity.ok("updated");
|
||||
}
|
||||
|
||||
|
||||
@PutMapping("/_email")
|
||||
@Operation(
|
||||
summary = "Update a user's email", description = "Update the email of the authenticated User"
|
||||
)
|
||||
public ResponseEntity<String> updateEmail(@RequestBody ChangeEmailRequest changeEmailRequest)
|
||||
throws AuthenticationException {
|
||||
final Long idOfUserToUpdate = authenticatedUserService.getAuthenticatedUser().getId();
|
||||
userService.updateEmail(idOfUserToUpdate, changeEmailRequest.password(), changeEmailRequest.newEmail());
|
||||
return ResponseEntity.ok("updated");
|
||||
}
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
package com.github.mdeluise.plantit.authentication;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(name = "User", description = "Represents a user of the system.")
|
||||
public class UserDTO {
|
||||
@Schema(description = "ID of the user.", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private Long id;
|
||||
@Schema(description = "Username of the user.")
|
||||
private String username;
|
||||
@Schema(description = "Email of the user.")
|
||||
private String email;
|
||||
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
final UserDTO userDTO = (UserDTO) o;
|
||||
return Objects.equals(id, userDTO.id) && Objects.equals(username, userDTO.username) &&
|
||||
Objects.equals(email, userDTO.email);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(id, username, email);
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
package com.github.mdeluise.plantit.authentication;
|
||||
|
||||
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
|
||||
import org.modelmapper.ModelMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class UserDTOConverter extends AbstractDTOConverter<User, UserDTO> {
|
||||
|
||||
@Autowired
|
||||
public UserDTOConverter(ModelMapper modelMapper) {
|
||||
super(modelMapper);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public User convertFromDTO(UserDTO dto) {
|
||||
return modelMapper.map(dto, User.class);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public UserDTO convertToDTO(User data) {
|
||||
return modelMapper.map(data, UserDTO.class);
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
package com.github.mdeluise.plantit.authentication;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
boolean existsByUsername(String username);
|
||||
|
||||
boolean existsByEmail(String email);
|
||||
}
|
||||
@ -1,176 +0,0 @@
|
||||
package com.github.mdeluise.plantit.authentication;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import javax.naming.AuthenticationException;
|
||||
|
||||
import com.github.mdeluise.plantit.exception.ResourceNotFoundException;
|
||||
import com.github.mdeluise.plantit.notification.email.EmailException;
|
||||
import com.github.mdeluise.plantit.notification.email.EmailService;
|
||||
import com.github.mdeluise.plantit.notification.password.TemporaryPassword;
|
||||
import com.github.mdeluise.plantit.notification.password.TemporaryPasswordService;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class UserService {
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder encoder;
|
||||
private final EmailService emailService;
|
||||
private final TemporaryPasswordService temporaryPasswordService;
|
||||
private final Logger logger = LoggerFactory.getLogger(UserService.class);
|
||||
|
||||
|
||||
@Autowired
|
||||
public UserService(UserRepository userRepository, PasswordEncoder encoder,
|
||||
EmailService emailService, TemporaryPasswordService temporaryPasswordService) {
|
||||
this.userRepository = userRepository;
|
||||
this.encoder = encoder;
|
||||
this.emailService = emailService;
|
||||
this.temporaryPasswordService = temporaryPasswordService;
|
||||
}
|
||||
|
||||
|
||||
public User get(String username) {
|
||||
return userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("username", username));
|
||||
}
|
||||
|
||||
|
||||
public User get(Long id) {
|
||||
return userRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException(id));
|
||||
}
|
||||
|
||||
|
||||
public List<User> getAll() {
|
||||
return userRepository.findAll();
|
||||
}
|
||||
|
||||
|
||||
public User save(String username, String plainPassword, String email) {
|
||||
final User user = new User();
|
||||
user.setUsername(username);
|
||||
user.setPassword(plainPassword);
|
||||
user.setEmail(email);
|
||||
return save(user);
|
||||
}
|
||||
|
||||
|
||||
@Transactional
|
||||
public User updateInternal(Long id, User updatedUser) {
|
||||
final User toUpdate = get(id);
|
||||
toUpdate.setUsername(updatedUser.getUsername());
|
||||
toUpdate.setLastLogin(updatedUser.getLastLogin());
|
||||
return userRepository.save(toUpdate);
|
||||
}
|
||||
|
||||
|
||||
public void updatePassword(Long userId, String currentPassword, String newPassword) throws AuthenticationException {
|
||||
final User toUpdate = get(userId);
|
||||
if (!encoder.matches(currentPassword, toUpdate.getPassword())) {
|
||||
final Optional<TemporaryPassword> optionalTemporaryPassword =
|
||||
temporaryPasswordService.get(toUpdate.getUsername());
|
||||
if (optionalTemporaryPassword.isEmpty() ||
|
||||
!encoder.matches(currentPassword, optionalTemporaryPassword.get().getPassword())) {
|
||||
logger.error(
|
||||
"Error while updating password for user {}, incorrect current password.", toUpdate.getUsername());
|
||||
throw new AuthenticationException("Current password does not match");
|
||||
} else {
|
||||
logger.debug("User {} updateInternal his password using a temporary password", toUpdate.getUsername());
|
||||
}
|
||||
}
|
||||
if (newPassword != null && !newPassword.isBlank() && !newPassword.equals(toUpdate.getPassword())) {
|
||||
final String encodedNewPassword = encoder.encode(newPassword);
|
||||
toUpdate.setPassword(encodedNewPassword);
|
||||
userRepository.save(toUpdate);
|
||||
temporaryPasswordService.removeIfExists(toUpdate.getUsername());
|
||||
logger.info("Password for user {} updated", toUpdate.getUsername());
|
||||
if (emailService.isEnabled()) {
|
||||
try {
|
||||
emailService.sendPasswordChangeNotification(toUpdate.getUsername(), toUpdate.getEmail());
|
||||
logger.info("Sent email to user in order to notify about the password change");
|
||||
} catch (EmailException e) {
|
||||
logger.error("Error while sending password change notification to user", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void updateUsername(Long userId, String password, String newUsername) throws AuthenticationException {
|
||||
final User toUpdate = get(userId);
|
||||
if (!encoder.matches(password, toUpdate.getPassword())) {
|
||||
logger.error("Error while updating username for user {}, incorrect current password.", toUpdate.getUsername());
|
||||
throw new AuthenticationException("Incorrect password");
|
||||
}
|
||||
if (!newUsername.equals(toUpdate.getUsername()) && existsByUsername(newUsername)) {
|
||||
logger.error("Error while updating username for user {}, new username already used.", toUpdate.getUsername());
|
||||
throw new IllegalArgumentException("New username already used");
|
||||
}
|
||||
if (newUsername != null && !newUsername.isBlank() && !newUsername.equals(toUpdate.getUsername())) {
|
||||
toUpdate.setUsername(newUsername);
|
||||
userRepository.save(toUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateEmail(Long userId, String password, String newEmail) throws AuthenticationException {
|
||||
final User toUpdate = get(userId);
|
||||
if (!encoder.matches(password, toUpdate.getPassword())) {
|
||||
logger.error("Error while updating email for user {}, incorrect current password.", toUpdate.getUsername());
|
||||
throw new AuthenticationException("Incorrect password");
|
||||
}
|
||||
if (!newEmail.equals(toUpdate.getUsername()) && existsByEmail(newEmail)) {
|
||||
logger.error("Error while updating email for user {}, new email already used.", toUpdate.getUsername());
|
||||
throw new IllegalArgumentException("New email already used");
|
||||
}
|
||||
if (newEmail != null && !newEmail.isBlank() && !newEmail.equals(toUpdate.getPassword())) {
|
||||
toUpdate.setEmail(newEmail);
|
||||
userRepository.save(toUpdate);
|
||||
logger.info("Email for user {} updated", toUpdate.getUsername());
|
||||
if (emailService.isEnabled()) {
|
||||
try {
|
||||
emailService.sendEmailChangeNotification(toUpdate.getUsername(), newEmail);
|
||||
logger.info("Sent email to user in order to notify about the email change");
|
||||
} catch (EmailException e) {
|
||||
logger.error("Error while sending email change notification to user", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void remove(Long id) {
|
||||
userRepository.deleteById(id);
|
||||
}
|
||||
|
||||
|
||||
@Transactional
|
||||
public User save(User entityToSave) {
|
||||
entityToSave.setPassword(encoder.encode(entityToSave.getPassword()));
|
||||
return userRepository.save(entityToSave);
|
||||
}
|
||||
|
||||
|
||||
public boolean existsByUsername(String username) {
|
||||
return userRepository.existsByUsername(username);
|
||||
}
|
||||
|
||||
|
||||
public void removeAll() {
|
||||
userRepository.deleteAll();
|
||||
}
|
||||
|
||||
|
||||
public long count() {
|
||||
return userRepository.count();
|
||||
}
|
||||
|
||||
|
||||
public boolean existsByEmail(String email) {
|
||||
return userRepository.existsByEmail(email);
|
||||
}
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
package com.github.mdeluise.plantit.authentication.payload.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
@Schema(description = "Represents a request to change the user password.")
|
||||
public record ChangeEmailRequest(
|
||||
@Schema(description = "Password.", example = "password1") @NotBlank String password,
|
||||
@Schema(description = "New email.", example = "foo@bar.com") @NotBlank String newEmail) {
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
package com.github.mdeluise.plantit.authentication.payload.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
@Schema(description = "Represents a request to change the user password.")
|
||||
public record ChangePasswordRequest(
|
||||
@Schema(description = "Current password.", example = "password1") @NotBlank String currentPassword,
|
||||
@Schema(description = "New password.", example = "password2") @NotBlank String newPassword) {
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
package com.github.mdeluise.plantit.authentication.payload.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
@Schema(description = "Represents a request to change the user username.")
|
||||
public record ChangeUsernameRequest(
|
||||
@Schema(description = "Password.", example = "password1") @NotBlank String password,
|
||||
@Schema(description = "New username.", example = "Username1") @NotBlank String newUsername) {
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
package com.github.mdeluise.plantit.authentication.payload.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
@Schema(description = "Represents a request to login into the system.")
|
||||
public record LoginRequest(
|
||||
@Schema(description = "Username to use to login.", example = "user") @NotBlank String username,
|
||||
@Schema(description = "Password to use to login.", example = "user") @NotBlank String password) {
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
package com.github.mdeluise.plantit.authentication.payload.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
@Schema(description = "Represents a request to register into the system.")
|
||||
public record SignupRequest(
|
||||
@Schema(description = "Username to use to register.", example = "admin") @NotBlank @Size(min = 3, max = 20)
|
||||
String username,
|
||||
@Schema(description = "Username to use to register.", example = "admin") @NotBlank @Size(min = 6, max = 40)
|
||||
String password,
|
||||
@Schema(description = "Email to use to register.", example = "foo@bar.com") @NotBlank @Size(min = 5, max = 70)
|
||||
String email) {
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
package com.github.mdeluise.plantit.authentication.payload.response;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import com.github.mdeluise.plantit.security.jwt.JwtTokenInfo;
|
||||
|
||||
public record UserInfoResponse(long id, String username, String email, Date lastLogin, JwtTokenInfo jwt) {
|
||||
}
|
||||
@ -1,225 +0,0 @@
|
||||
package com.github.mdeluise.plantit.botanicalinfo;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import com.github.mdeluise.plantit.authentication.User;
|
||||
import com.github.mdeluise.plantit.botanicalinfo.care.PlantCareInfo;
|
||||
import com.github.mdeluise.plantit.image.BotanicalInfoImage;
|
||||
import com.github.mdeluise.plantit.image.ImageTarget;
|
||||
import com.github.mdeluise.plantit.plant.Plant;
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.CollectionTable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.Embedded;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Transient;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
@Entity
|
||||
@Table(name = "botanical_infos")
|
||||
public class BotanicalInfo implements Serializable, ImageTarget {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
private Long id;
|
||||
// https://landscapeplants.oregonstate.edu/scientific-plant-names-binomial-nomenclature
|
||||
@Transient
|
||||
private String scientificName;
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(name = "synonyms")
|
||||
@Column(name = "synonym_value")
|
||||
private Set<String> synonyms = new HashSet<>();
|
||||
private String family;
|
||||
private String genus;
|
||||
private String species;
|
||||
@Embedded
|
||||
private PlantCareInfo plantCareInfo = new PlantCareInfo();
|
||||
@NotNull
|
||||
@OneToMany(mappedBy = "botanicalInfo")
|
||||
private Set<Plant> plants = new HashSet<>();
|
||||
@OneToOne(mappedBy = "target", cascade = CascadeType.ALL)
|
||||
private BotanicalInfoImage image;
|
||||
@NotNull
|
||||
@Enumerated(EnumType.STRING)
|
||||
private BotanicalInfoCreator creator;
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "user_creator_id")
|
||||
private User userCreator;
|
||||
private String externalId;
|
||||
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
public String getScientificName() {
|
||||
return species;
|
||||
}
|
||||
|
||||
|
||||
public Set<String> getSynonyms() {
|
||||
return synonyms;
|
||||
}
|
||||
|
||||
|
||||
public void setSynonyms(Set<String> synonyms) {
|
||||
this.synonyms = synonyms;
|
||||
}
|
||||
|
||||
|
||||
public String getFamily() {
|
||||
return family;
|
||||
}
|
||||
|
||||
|
||||
public void setFamily(String family) {
|
||||
this.family = family;
|
||||
}
|
||||
|
||||
|
||||
public String getGenus() {
|
||||
return genus;
|
||||
}
|
||||
|
||||
|
||||
public void setGenus(String genus) {
|
||||
this.genus = genus;
|
||||
}
|
||||
|
||||
|
||||
public String getSpecies() {
|
||||
return species;
|
||||
}
|
||||
|
||||
|
||||
public void setSpecies(String species) {
|
||||
this.species = species;
|
||||
}
|
||||
|
||||
|
||||
public PlantCareInfo getPlantCareInfo() {
|
||||
// see https://coderanch.com/t/629485/databases/columns-Embedded-field-NULL-JPA
|
||||
return plantCareInfo != null ? plantCareInfo : new PlantCareInfo();
|
||||
}
|
||||
|
||||
|
||||
public void setPlantCareInfo(PlantCareInfo plantCareInfo) {
|
||||
this.plantCareInfo = plantCareInfo;
|
||||
}
|
||||
|
||||
|
||||
public Set<Plant> getPlants() {
|
||||
return plants;
|
||||
}
|
||||
|
||||
|
||||
public void setPlants(Set<Plant> plants) {
|
||||
this.plants = plants;
|
||||
}
|
||||
|
||||
|
||||
public BotanicalInfoImage getImage() {
|
||||
return image;
|
||||
}
|
||||
|
||||
|
||||
public void setImage(BotanicalInfoImage image) {
|
||||
this.image = image;
|
||||
}
|
||||
|
||||
|
||||
public BotanicalInfoCreator getCreator() {
|
||||
return creator;
|
||||
}
|
||||
|
||||
|
||||
public void setCreator(BotanicalInfoCreator creator) {
|
||||
this.creator = creator;
|
||||
}
|
||||
|
||||
|
||||
public User getUserCreator() {
|
||||
return userCreator;
|
||||
}
|
||||
|
||||
|
||||
public void setUserCreator(User userCreator) {
|
||||
this.userCreator = userCreator;
|
||||
if (userCreator != null) {
|
||||
this.creator = BotanicalInfoCreator.USER;
|
||||
this.externalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public String getExternalId() {
|
||||
return externalId;
|
||||
}
|
||||
|
||||
|
||||
public void setExternalId(String externalId) {
|
||||
this.externalId = externalId;
|
||||
}
|
||||
|
||||
|
||||
public boolean isAccessibleToUser(User user) {
|
||||
return creator != BotanicalInfoCreator.USER || userCreator.equals(user);
|
||||
}
|
||||
|
||||
|
||||
public boolean isUserCreated() {
|
||||
return creator == BotanicalInfoCreator.USER;
|
||||
}
|
||||
|
||||
|
||||
public boolean isPlantCareEmpty() {
|
||||
return getPlantCareInfo().isAllNull();
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
final BotanicalInfo that = (BotanicalInfo) o;
|
||||
return fieldEquals(that);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(scientificName, family, genus, species);
|
||||
}
|
||||
|
||||
|
||||
@SuppressWarnings("BooleanExpressionComplexity") //FIXME
|
||||
private boolean fieldEquals(BotanicalInfo that) {
|
||||
return Objects.equals(scientificName, that.scientificName) && Objects.equals(family, that.family) &&
|
||||
Objects.equals(genus, that.genus) && Objects.equals(species, that.species) &&
|
||||
Objects.equals(externalId, that.externalId);
|
||||
}
|
||||
}
|
||||
@ -1,118 +0,0 @@
|
||||
package com.github.mdeluise.plantit.botanicalinfo;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.github.mdeluise.plantit.common.MessageResponse;
|
||||
import com.github.mdeluise.plantit.plantinfo.PlantInfoExtractorFacade;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/botanical-info")
|
||||
@Tag(name = "Botanical Info", description = "Endpoints for operations on botanical info.")
|
||||
public class BotanicalInfoController {
|
||||
private final BotanicalInfoDTOConverter botanicalInfoDtoConverter;
|
||||
private final BotanicalInfoService botanicalInfoService;
|
||||
private final PlantInfoExtractorFacade plantInfoExtractorFacade;
|
||||
|
||||
|
||||
@Autowired
|
||||
public BotanicalInfoController(BotanicalInfoDTOConverter botanicalInfoDtoConverter,
|
||||
BotanicalInfoService botanicalInfoService,
|
||||
PlantInfoExtractorFacade plantInfoExtractorFacade) {
|
||||
this.botanicalInfoDtoConverter = botanicalInfoDtoConverter;
|
||||
this.botanicalInfoService = botanicalInfoService;
|
||||
this.plantInfoExtractorFacade = plantInfoExtractorFacade;
|
||||
}
|
||||
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Get all the botanical info", description = "Get all the botanical info.")
|
||||
public ResponseEntity<List<BotanicalInfoDTO>> getAll(
|
||||
@RequestParam(defaultValue = "5", required = false) Integer size) {
|
||||
final Collection<BotanicalInfo> result = plantInfoExtractorFacade.getAll(size);
|
||||
final List<BotanicalInfoDTO> convertedResult =
|
||||
result.stream().map(botanicalInfoDtoConverter::convertToDTO).collect(Collectors.toList());
|
||||
return ResponseEntity.ok(convertedResult);
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/partial/{partialScientificName}")
|
||||
@Operation(
|
||||
summary = "Get all the botanical info matching the provided partial scientific name",
|
||||
description = "Get all the botanical info, according to the `partialScientificName` parameter"
|
||||
)
|
||||
public ResponseEntity<List<BotanicalInfoDTO>> getPartial(
|
||||
@RequestParam(defaultValue = "5", required = false) Integer size,
|
||||
@PathVariable String partialScientificName) {
|
||||
final Collection<BotanicalInfo> result = plantInfoExtractorFacade.extractPlants(
|
||||
partialScientificName, size);
|
||||
final List<BotanicalInfoDTO> convertedResult =
|
||||
result.stream().map(botanicalInfoDtoConverter::convertToDTO).collect(Collectors.toList());
|
||||
return ResponseEntity.ok(convertedResult);
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/{botanicalInfoId}/_count")
|
||||
@Operation(
|
||||
summary = "Count the existing plant for a botanical info.",
|
||||
description = "Count the existing plants with the botanical info which id is `botanicalInfoId`."
|
||||
)
|
||||
public ResponseEntity<Integer> count(@PathVariable Long botanicalInfoId) {
|
||||
return ResponseEntity.ok(botanicalInfoService.countPlants(botanicalInfoId));
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/_count")
|
||||
@Operation(summary = "Count the botanical info.", description = "Count all the botanical info.")
|
||||
public ResponseEntity<Long> countAll() {
|
||||
return ResponseEntity.ok(botanicalInfoService.count());
|
||||
}
|
||||
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Save a new botanical info.", description = "Save a new botanical info.")
|
||||
public ResponseEntity<BotanicalInfoDTO> save(@RequestBody BotanicalInfoDTO toSave) throws MalformedURLException {
|
||||
final BotanicalInfo result = botanicalInfoService.save(botanicalInfoDtoConverter.convertFromDTO(toSave));
|
||||
return ResponseEntity.ok(botanicalInfoDtoConverter.convertToDTO(result));
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Get a botanical info.", description = "Get the botanical info with the specified `id`.")
|
||||
public ResponseEntity<BotanicalInfoDTO> get(@PathVariable Long id) {
|
||||
final BotanicalInfo result = botanicalInfoService.get(id);
|
||||
return ResponseEntity.ok(botanicalInfoDtoConverter.convertToDTO(result));
|
||||
}
|
||||
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "Update a botanical info.", description = "Update the botanical info with the specified `id`.")
|
||||
public ResponseEntity<BotanicalInfoDTO> update(@PathVariable Long id, @RequestBody BotanicalInfoDTO updated)
|
||||
throws MalformedURLException {
|
||||
final BotanicalInfo result = botanicalInfoService.update(id, botanicalInfoDtoConverter.convertFromDTO(updated));
|
||||
return ResponseEntity.ok(botanicalInfoDtoConverter.convertToDTO(result));
|
||||
}
|
||||
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "Delete a botanical info.", description = "Delete the botanical info with the specified `id`.")
|
||||
public ResponseEntity<MessageResponse> remove(@PathVariable Long id) {
|
||||
botanicalInfoService.delete(id);
|
||||
return ResponseEntity.ok(new MessageResponse("Success"));
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
package com.github.mdeluise.plantit.botanicalinfo;
|
||||
|
||||
public enum BotanicalInfoCreator {
|
||||
USER,
|
||||
TREFLE,
|
||||
FLORA_CODEX,
|
||||
}
|
||||
@ -1,187 +0,0 @@
|
||||
package com.github.mdeluise.plantit.botanicalinfo;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import com.github.mdeluise.plantit.botanicalinfo.care.PlantCareInfoDTO;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(name = "Botanical info", description = "Represents a plant's botanical info.")
|
||||
public class BotanicalInfoDTO {
|
||||
@Schema(description = "ID of the botanical info.", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private Long id;
|
||||
@Schema(description = "Scientific name of the botanical info.", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String scientificName;
|
||||
@Schema(description = "Synonyms of the botanical info.")
|
||||
private Set<String> synonyms = new HashSet<>();
|
||||
@Schema(description = "Family of the botanical info.")
|
||||
private String family;
|
||||
@Schema(description = "Genus of the botanical info.")
|
||||
private String genus;
|
||||
@Schema(description = "Species of the botanical info.")
|
||||
private String species;
|
||||
@Schema(description = "Care information of the botanical info.")
|
||||
private PlantCareInfoDTO plantCareInfo;
|
||||
@Schema(description = "ID of the botanical info image.", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String imageId;
|
||||
@Schema(description = "URL of the botanical info image.")
|
||||
private String imageUrl;
|
||||
@Schema(description = "Content of the botanical info image.", accessMode = Schema.AccessMode.WRITE_ONLY)
|
||||
private byte[] imageContent;
|
||||
@Schema(description = "Content type of the botanical info image.", accessMode = Schema.AccessMode.WRITE_ONLY)
|
||||
private String imageContentType;
|
||||
@Schema(description = "Creator of the botanical info")
|
||||
private String creator;
|
||||
@Schema(description = "ID of the botanical info in the creator service")
|
||||
private String externalId;
|
||||
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
public String getScientificName() {
|
||||
return scientificName;
|
||||
}
|
||||
|
||||
|
||||
public void setScientificName(String scientificName) {
|
||||
this.scientificName = scientificName;
|
||||
}
|
||||
|
||||
|
||||
public Set<String> getSynonyms() {
|
||||
return synonyms;
|
||||
}
|
||||
|
||||
|
||||
public void setSynonyms(Set<String> synonyms) {
|
||||
this.synonyms = synonyms;
|
||||
}
|
||||
|
||||
|
||||
public String getFamily() {
|
||||
return family;
|
||||
}
|
||||
|
||||
|
||||
public void setFamily(String family) {
|
||||
this.family = family;
|
||||
}
|
||||
|
||||
|
||||
public String getGenus() {
|
||||
return genus;
|
||||
}
|
||||
|
||||
|
||||
public void setGenus(String genus) {
|
||||
this.genus = genus;
|
||||
}
|
||||
|
||||
|
||||
public String getSpecies() {
|
||||
return species;
|
||||
}
|
||||
|
||||
|
||||
public void setSpecies(String species) {
|
||||
this.species = species;
|
||||
}
|
||||
|
||||
|
||||
public PlantCareInfoDTO getPlantCareInfo() {
|
||||
return plantCareInfo;
|
||||
}
|
||||
|
||||
|
||||
public void setPlantCareInfo(PlantCareInfoDTO plantCareInfo) {
|
||||
this.plantCareInfo = plantCareInfo;
|
||||
}
|
||||
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
|
||||
public String getImageId() {
|
||||
return imageId;
|
||||
}
|
||||
|
||||
|
||||
public void setImageId(String imageId) {
|
||||
this.imageId = imageId;
|
||||
}
|
||||
|
||||
|
||||
public byte[] getImageContent() {
|
||||
return imageContent;
|
||||
}
|
||||
|
||||
|
||||
public void setImageContent(byte[] imageContent) {
|
||||
this.imageContent = imageContent;
|
||||
}
|
||||
|
||||
|
||||
public String getImageContentType() {
|
||||
return imageContentType;
|
||||
}
|
||||
|
||||
|
||||
public void setImageContentType(String imageContentType) {
|
||||
this.imageContentType = imageContentType;
|
||||
}
|
||||
|
||||
|
||||
public void setCreator(String creator) {
|
||||
this.creator = creator;
|
||||
}
|
||||
|
||||
|
||||
public String getCreator() {
|
||||
return creator;
|
||||
}
|
||||
|
||||
|
||||
public String getExternalId() {
|
||||
return externalId;
|
||||
}
|
||||
|
||||
|
||||
public void setExternalId(String externalId) {
|
||||
this.externalId = externalId;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
final BotanicalInfoDTO that = (BotanicalInfoDTO) o;
|
||||
return Objects.equals(id, that.id) || Objects.equals(species, that.species);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(id, species);
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
package com.github.mdeluise.plantit.botanicalinfo;
|
||||
|
||||
import com.github.mdeluise.plantit.botanicalinfo.care.PlantCareInfo;
|
||||
import com.github.mdeluise.plantit.botanicalinfo.care.PlantCareInfoDTO;
|
||||
import com.github.mdeluise.plantit.botanicalinfo.care.PlantCareInfoDTOConverter;
|
||||
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
|
||||
import org.modelmapper.ModelMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class BotanicalInfoDTOConverter extends AbstractDTOConverter<BotanicalInfo, BotanicalInfoDTO> {
|
||||
private final PlantCareInfoDTOConverter plantCareInfoDtoConverter;
|
||||
|
||||
|
||||
@Autowired
|
||||
public BotanicalInfoDTOConverter(ModelMapper modelMapper, PlantCareInfoDTOConverter plantCareInfoDtoConverter) {
|
||||
super(modelMapper);
|
||||
this.plantCareInfoDtoConverter = plantCareInfoDtoConverter;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public BotanicalInfo convertFromDTO(BotanicalInfoDTO dto) {
|
||||
final BotanicalInfo result = modelMapper.map(dto, BotanicalInfo.class);
|
||||
final PlantCareInfo plantCareInfo = plantCareInfoDtoConverter.convertFromDTO(dto.getPlantCareInfo());
|
||||
result.setPlantCareInfo(plantCareInfo);
|
||||
if (dto.getImageContentType() != null) {
|
||||
result.getImage().setContentType(dto.getImageContentType());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public BotanicalInfoDTO convertToDTO(BotanicalInfo data) {
|
||||
final BotanicalInfoDTO result = modelMapper.map(data, BotanicalInfoDTO.class);
|
||||
final PlantCareInfoDTO plantCareInfoDTO = plantCareInfoDtoConverter.convertToDTO(data.getPlantCareInfo());
|
||||
result.setPlantCareInfo(plantCareInfoDTO);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
package com.github.mdeluise.plantit.botanicalinfo;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import com.github.mdeluise.plantit.authentication.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface BotanicalInfoRepository extends JpaRepository<BotanicalInfo, Long> {
|
||||
|
||||
List<BotanicalInfo> findBySpeciesContainsIgnoreCase(String partialScientificName);
|
||||
|
||||
@Query("SELECT b FROM BotanicalInfo b JOIN b.synonyms s WHERE LOWER(s) LIKE LOWER(:synonym)")
|
||||
List<BotanicalInfo> findBySynonymsContainsIgnoreCase(@Param("synonym") String synonym);
|
||||
|
||||
default List<BotanicalInfo> getBySpeciesOrSynonym(String search) {
|
||||
final Set<BotanicalInfo> result = new HashSet<>();
|
||||
result.addAll(findBySpeciesContainsIgnoreCase(search));
|
||||
result.addAll(findBySynonymsContainsIgnoreCase(search));
|
||||
return result.stream().toList();
|
||||
}
|
||||
|
||||
List<BotanicalInfo> findAll();
|
||||
|
||||
List<BotanicalInfo> findAllBySpecies(String species);
|
||||
|
||||
List<BotanicalInfo> findAllByCreatorAndExternalId(BotanicalInfoCreator creator, String externalId);
|
||||
|
||||
boolean existsBySpeciesAndCreatorAndUserCreator(String species, BotanicalInfoCreator creator, User userCreator);
|
||||
|
||||
Optional<BotanicalInfo> findBySpeciesAndCreatorAndUserCreator(String species, BotanicalInfoCreator creator, User userCreator);
|
||||
}
|
||||
@ -1,318 +0,0 @@
|
||||
package com.github.mdeluise.plantit.botanicalinfo;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.github.mdeluise.plantit.authentication.User;
|
||||
import com.github.mdeluise.plantit.common.AuthenticatedUserService;
|
||||
import com.github.mdeluise.plantit.exception.ResourceNotFoundException;
|
||||
import com.github.mdeluise.plantit.exception.UnauthorizedException;
|
||||
import com.github.mdeluise.plantit.image.BotanicalInfoImage;
|
||||
import com.github.mdeluise.plantit.image.EntityImage;
|
||||
import com.github.mdeluise.plantit.image.storage.ImageStorageService;
|
||||
import com.github.mdeluise.plantit.plant.Plant;
|
||||
import com.github.mdeluise.plantit.plant.PlantRepository;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class BotanicalInfoService {
|
||||
private final AuthenticatedUserService authenticatedUserService;
|
||||
private final BotanicalInfoRepository botanicalInfoRepository;
|
||||
private final ImageStorageService imageStorageService;
|
||||
private final PlantRepository plantRepository;
|
||||
private final Logger logger = LoggerFactory.getLogger(BotanicalInfoService.class);
|
||||
|
||||
|
||||
@Autowired
|
||||
public BotanicalInfoService(AuthenticatedUserService authenticatedUserService,
|
||||
BotanicalInfoRepository botanicalInfoRepository,
|
||||
ImageStorageService imageStorageService, PlantRepository plantRepository) {
|
||||
this.authenticatedUserService = authenticatedUserService;
|
||||
this.botanicalInfoRepository = botanicalInfoRepository;
|
||||
this.imageStorageService = imageStorageService;
|
||||
this.plantRepository = plantRepository;
|
||||
}
|
||||
|
||||
|
||||
public Set<BotanicalInfo> getByPartialScientificName(String partialScientificName, int size) {
|
||||
logger.debug(String.format("Search for DB saved botanical info matching '%s' scientific name (max size %s)",
|
||||
partialScientificName, size
|
||||
));
|
||||
final List<BotanicalInfo> result = botanicalInfoRepository
|
||||
.getBySpeciesOrSynonym(partialScientificName).stream()
|
||||
.filter(botanicalInfo -> botanicalInfo.isAccessibleToUser(
|
||||
authenticatedUserService.getAuthenticatedUser()))
|
||||
.limit(size)
|
||||
.toList();
|
||||
return new HashSet<>(result.subList(0, Math.min(size, result.size())));
|
||||
}
|
||||
|
||||
|
||||
public Set<BotanicalInfo> getAll(int size) {
|
||||
logger.debug(String.format("Search for DB saved botanical info (max size %s)", size));
|
||||
final List<BotanicalInfo> result = botanicalInfoRepository.findAll().stream().filter(
|
||||
botanicalInfo -> botanicalInfo.isAccessibleToUser(authenticatedUserService.getAuthenticatedUser()))
|
||||
.limit(size).toList();
|
||||
return new HashSet<>(result);
|
||||
}
|
||||
|
||||
|
||||
public int countPlants(Long botanicalInfoId) {
|
||||
return botanicalInfoRepository.findById(botanicalInfoId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException(botanicalInfoId)).getPlants()
|
||||
.stream()
|
||||
.filter(
|
||||
pl -> pl.getOwner().equals(authenticatedUserService.getAuthenticatedUser()))
|
||||
.collect(Collectors.toSet())
|
||||
.size();
|
||||
}
|
||||
|
||||
|
||||
public long count() {
|
||||
return plantRepository.findAllByOwner(authenticatedUserService.getAuthenticatedUser(), Pageable.unpaged())
|
||||
.getContent()
|
||||
.stream()
|
||||
.map(entity -> entity.getBotanicalInfo().getId())
|
||||
.collect(Collectors.toSet()).size();
|
||||
}
|
||||
|
||||
|
||||
@CacheEvict(cacheNames = "botanical-info", allEntries = true)
|
||||
@Transactional
|
||||
public BotanicalInfo save(BotanicalInfo toSave) throws MalformedURLException {
|
||||
checkSpeciesNotDuplicatedForSameCreator(toSave);
|
||||
if (toSave.isUserCreated()) {
|
||||
toSave.setUserCreator(authenticatedUserService.getAuthenticatedUser());
|
||||
}
|
||||
removeDuplicatedCaseInsensitiveSynonyms(toSave);
|
||||
final BotanicalInfoImage imageToSave = toSave.getImage();
|
||||
toSave.setImage(null);
|
||||
final BotanicalInfo result = botanicalInfoRepository.save(toSave);
|
||||
try {
|
||||
linkNewImage(imageToSave, result);
|
||||
} catch (MalformedURLException e) {
|
||||
logger.error("Error while setting the image for the new species", e);
|
||||
throw e;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
private void linkNewImage(BotanicalInfoImage toSave, BotanicalInfo toUpdate) throws MalformedURLException {
|
||||
if (toSave == null) {
|
||||
logger.debug("Species {} does not have a linked image", toUpdate.getSpecies());
|
||||
return;
|
||||
}
|
||||
if (toSave.getContent() != null) {
|
||||
logger.debug("Species {} have a linked image's content, updating...", toUpdate.getSpecies());
|
||||
final BotanicalInfoImage saved =
|
||||
(BotanicalInfoImage) imageStorageService.saveBotanicalInfoThumbnailImage(toSave.getContent(), toSave.getContentType(), toUpdate);
|
||||
toUpdate.setImage(saved);
|
||||
} else if (toSave.getUrl() != null && !toSave.getUrl().isBlank()) {
|
||||
logger.debug("Species {} have a linked image's url, updating...", toUpdate.getSpecies());
|
||||
final BotanicalInfoImage saved = (BotanicalInfoImage) imageStorageService.save(toSave.getUrl(), toUpdate);
|
||||
toUpdate.setImage(saved);
|
||||
} else if (toSave.getId() != null && !toSave.getId().isBlank()) {
|
||||
logger.debug("Species {} have a linked image's id, updating...", toUpdate.getSpecies());
|
||||
final BotanicalInfoImage entityImage = (BotanicalInfoImage) imageStorageService.get(toSave.getId());
|
||||
toUpdate.setImage(entityImage);
|
||||
botanicalInfoRepository.save(toUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void checkSpeciesNotDuplicatedForSameCreator(BotanicalInfo toSave) {
|
||||
final String species = toSave.getSpecies();
|
||||
final BotanicalInfoCreator creator = toSave.getCreator();
|
||||
final User userCreator = toSave.getUserCreator();
|
||||
if (botanicalInfoRepository.existsBySpeciesAndCreatorAndUserCreator(species, creator, userCreator)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Species %s with creator %s already exists", species, creator));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public BotanicalInfo get(Long id) {
|
||||
final BotanicalInfo result =
|
||||
botanicalInfoRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException(id));
|
||||
if (!result.isAccessibleToUser(authenticatedUserService.getAuthenticatedUser())) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
public BotanicalInfo getInternal(String scientificName) {
|
||||
return botanicalInfoRepository.getBySpeciesOrSynonym(scientificName).get(0);
|
||||
}
|
||||
|
||||
|
||||
@Transactional
|
||||
@CacheEvict(cacheNames = {"botanical-info", "plants"}, allEntries = true)
|
||||
public void delete(Long id) {
|
||||
final BotanicalInfo toDelete = get(id);
|
||||
if (toDelete.getImage() != null) {
|
||||
imageStorageService.remove(toDelete.getImage().getId());
|
||||
}
|
||||
|
||||
// cascade will not remove plant images
|
||||
toDelete.getPlants().forEach(pl -> {
|
||||
pl.getImages().forEach(im -> imageStorageService.remove(im.getId()));
|
||||
plantRepository.delete(pl);
|
||||
});
|
||||
|
||||
botanicalInfoRepository.deleteById(id);
|
||||
}
|
||||
|
||||
|
||||
@Transactional
|
||||
public void deleteInternal(BotanicalInfo toDelete) {
|
||||
imageStorageService.removeAll();
|
||||
botanicalInfoRepository.delete(toDelete);
|
||||
}
|
||||
|
||||
|
||||
@Transactional
|
||||
public void deleteAll() {
|
||||
botanicalInfoRepository.findAll().forEach(this::deleteInternal);
|
||||
}
|
||||
|
||||
|
||||
@Transactional
|
||||
@CacheEvict(cacheNames = {"botanical-info", "plants"}, allEntries = true)
|
||||
public BotanicalInfo update(Long id, BotanicalInfo updated) throws MalformedURLException {
|
||||
final BotanicalInfo toUpdate = get(id);
|
||||
if (!toUpdate.isAccessibleToUser(authenticatedUserService.getAuthenticatedUser())) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
final String species = updated.getSpecies();
|
||||
final BotanicalInfoCreator creator = updated.getCreator();
|
||||
final User userCreator = updated.getUserCreator();
|
||||
final Optional<BotanicalInfo> optionalDuplicatedSpecies =
|
||||
botanicalInfoRepository.findBySpeciesAndCreatorAndUserCreator(species, updated.getCreator(), userCreator);
|
||||
final boolean existDistinctDuplicate =
|
||||
optionalDuplicatedSpecies.isPresent() && !optionalDuplicatedSpecies.get().getId().equals(updated.getId());
|
||||
if (existDistinctDuplicate) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Species %s with creator %s already exists", species, creator));
|
||||
}
|
||||
|
||||
final BotanicalInfoImage updatedImage = updated.getImage();
|
||||
updated.setImage(toUpdate.getImage());
|
||||
|
||||
if (toUpdate.isUserCreated()) {
|
||||
final BotanicalInfo saved = updateUserCreatedBotanicalInfo(updated, toUpdate);
|
||||
return updateImage(updatedImage, saved);
|
||||
}
|
||||
logger.info("Trying to updateInternal a NON custom botanical info. Creating custom copy...");
|
||||
final BotanicalInfo userCreatedCopy = createUserCreatedCopy(toUpdate);
|
||||
removeDuplicatedCaseInsensitiveSynonyms(updated);
|
||||
final BotanicalInfo result = updateUserCreatedBotanicalInfo(updated, userCreatedCopy);
|
||||
try {
|
||||
return updateImage(updatedImage, result);
|
||||
} catch (MalformedURLException e) {
|
||||
logger.error("Error while updating image for species {}", updated.getSpecies(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private BotanicalInfo updateImage(BotanicalInfoImage updatedImage, BotanicalInfo toUpdate) throws MalformedURLException {
|
||||
final BotanicalInfoImage oldImage = toUpdate.getImage();
|
||||
if (oldImage != updatedImage) { // both not null
|
||||
linkNewImage(updatedImage, toUpdate);
|
||||
if (oldImage != null && (updatedImage == null || !updatedImage.equals(oldImage))) {
|
||||
imageStorageService.remove(oldImage.getId());
|
||||
}
|
||||
}
|
||||
return toUpdate;
|
||||
}
|
||||
|
||||
|
||||
private BotanicalInfo updateUserCreatedBotanicalInfo(BotanicalInfo updated, BotanicalInfo toUpdate) {
|
||||
toUpdate.setUserCreator(authenticatedUserService.getAuthenticatedUser());
|
||||
toUpdate.setFamily(updated.getFamily());
|
||||
toUpdate.setGenus(updated.getGenus());
|
||||
toUpdate.setSpecies(updated.getSpecies());
|
||||
toUpdate.setPlantCareInfo(updated.getPlantCareInfo());
|
||||
toUpdate.setSynonyms(new HashSet<>(updated.getSynonyms()));
|
||||
return botanicalInfoRepository.save(toUpdate);
|
||||
}
|
||||
|
||||
|
||||
private BotanicalInfo createUserCreatedCopy(BotanicalInfo toCopy) throws MalformedURLException {
|
||||
BotanicalInfo userCreatedCopy = new BotanicalInfo();
|
||||
userCreatedCopy.setUserCreator(authenticatedUserService.getAuthenticatedUser());
|
||||
userCreatedCopy.setFamily(toCopy.getFamily());
|
||||
userCreatedCopy.setGenus(toCopy.getGenus());
|
||||
userCreatedCopy.setSpecies(toCopy.getSpecies());
|
||||
userCreatedCopy.setPlantCareInfo(toCopy.getPlantCareInfo());
|
||||
userCreatedCopy.setSynonyms(new HashSet<>(toCopy.getSynonyms()));
|
||||
|
||||
userCreatedCopy = save(userCreatedCopy);
|
||||
|
||||
if (toCopy.getImage() != null) {
|
||||
logger.debug("Copy botanical info thumbnail...");
|
||||
final EntityImage toClone = imageStorageService.get(toCopy.getImage().getId());
|
||||
final EntityImage clonedEntityImage = imageStorageService.clone(toClone.getId(), userCreatedCopy);
|
||||
userCreatedCopy.setImage((BotanicalInfoImage) clonedEntityImage);
|
||||
}
|
||||
|
||||
logger.debug("Move botanical info plants to the custom copy...");
|
||||
final BotanicalInfo finalUserCreatedCopy = userCreatedCopy;
|
||||
toCopy.getPlants().forEach(pl -> {
|
||||
if (pl.getOwner() != authenticatedUserService.getAuthenticatedUser()) {
|
||||
return;
|
||||
}
|
||||
final Plant toChangeBotanicalInfo =
|
||||
plantRepository.findById(pl.getId()).orElseThrow(() -> new ResourceNotFoundException(pl.getId()));
|
||||
toChangeBotanicalInfo.setBotanicalInfo(finalUserCreatedCopy);
|
||||
plantRepository.save(toChangeBotanicalInfo);
|
||||
});
|
||||
return userCreatedCopy;
|
||||
}
|
||||
|
||||
|
||||
public boolean existsSpecies(String species) {
|
||||
return botanicalInfoRepository.findAllBySpecies(species).stream().anyMatch(
|
||||
botanicalInfo -> botanicalInfo.isAccessibleToUser(authenticatedUserService.getAuthenticatedUser()));
|
||||
}
|
||||
|
||||
|
||||
public boolean existsExternalId(BotanicalInfoCreator creator, String externalId) {
|
||||
return botanicalInfoRepository.findAllByCreatorAndExternalId(creator, externalId).stream().anyMatch(
|
||||
botanicalInfo -> botanicalInfo.isAccessibleToUser(authenticatedUserService.getAuthenticatedUser()));
|
||||
}
|
||||
|
||||
|
||||
private void removeDuplicatedCaseInsensitiveSynonyms(BotanicalInfo botanicalInfo) {
|
||||
final Set<String> newSynonyms = new HashSet<>();
|
||||
botanicalInfo.getSynonyms().forEach(synonym -> {
|
||||
if (!containsCaseInsensitive(newSynonyms, synonym)) {
|
||||
newSynonyms.add(synonym);
|
||||
}
|
||||
});
|
||||
botanicalInfo.setSynonyms(newSynonyms);
|
||||
}
|
||||
|
||||
|
||||
private boolean containsCaseInsensitive(Set<String> set, String value) {
|
||||
for (String str : set) {
|
||||
if (str.equalsIgnoreCase(value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
package com.github.mdeluise.plantit.botanicalinfo.care;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import jakarta.persistence.Embeddable;
|
||||
|
||||
@Embeddable
|
||||
public class PlantCareInfo implements Serializable {
|
||||
private Integer light;
|
||||
private Integer humidity;
|
||||
private Double minTemp;
|
||||
private Double maxTemp;
|
||||
private Double phMax;
|
||||
private Double phMin;
|
||||
|
||||
|
||||
public PlantCareInfo() {
|
||||
}
|
||||
|
||||
|
||||
public Integer getLight() {
|
||||
return light;
|
||||
}
|
||||
|
||||
|
||||
public void setLight(Integer light) {
|
||||
this.light = light;
|
||||
}
|
||||
|
||||
|
||||
public Integer getHumidity() {
|
||||
return humidity;
|
||||
}
|
||||
|
||||
|
||||
public void setHumidity(Integer humidity) {
|
||||
this.humidity = humidity;
|
||||
}
|
||||
|
||||
|
||||
public Double getMinTemp() {
|
||||
return minTemp;
|
||||
}
|
||||
|
||||
|
||||
public void setMinTemp(Double minTemp) {
|
||||
this.minTemp = minTemp;
|
||||
}
|
||||
|
||||
|
||||
public Double getMaxTemp() {
|
||||
return maxTemp;
|
||||
}
|
||||
|
||||
|
||||
public void setMaxTemp(Double maxTemp) {
|
||||
this.maxTemp = maxTemp;
|
||||
}
|
||||
|
||||
|
||||
public Double getPhMax() {
|
||||
return phMax;
|
||||
}
|
||||
|
||||
|
||||
public void setPhMax(Double phMax) {
|
||||
this.phMax = phMax;
|
||||
}
|
||||
|
||||
|
||||
public Double getPhMin() {
|
||||
return phMin;
|
||||
}
|
||||
|
||||
|
||||
public void setPhMin(Double phMi) {
|
||||
this.phMin = phMi;
|
||||
}
|
||||
|
||||
|
||||
public boolean isAllNull() {
|
||||
return Stream.of(light, humidity, minTemp, maxTemp, phMin, phMax).allMatch(Objects::isNull);
|
||||
}
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
package com.github.mdeluise.plantit.botanicalinfo.care;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(name = "Plant care info", description = "Represents a plant's care info.")
|
||||
public class PlantCareInfoDTO {
|
||||
@Schema(description = "Light requirement")
|
||||
private Integer light;
|
||||
@Schema(description = "Humidity requirement")
|
||||
private Integer humidity;
|
||||
@Schema(description = "Minimum temperature requirement")
|
||||
private Double minTemp;
|
||||
@Schema(description = "Maximum temperature requirement")
|
||||
private Double maxTemp;
|
||||
@Schema(description = "Maximum PH requirement")
|
||||
private Double phMax;
|
||||
@Schema(description = "Minimum PH requirement")
|
||||
private Double phMin;
|
||||
@Schema(description = "Are all fields null?", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private boolean allNull;
|
||||
|
||||
|
||||
public Integer getLight() {
|
||||
return light;
|
||||
}
|
||||
|
||||
|
||||
public void setLight(Integer light) {
|
||||
this.light = light;
|
||||
}
|
||||
|
||||
|
||||
public Integer getHumidity() {
|
||||
return humidity;
|
||||
}
|
||||
|
||||
|
||||
public void setHumidity(Integer humidity) {
|
||||
this.humidity = humidity;
|
||||
}
|
||||
|
||||
|
||||
public Double getMinTemp() {
|
||||
return minTemp;
|
||||
}
|
||||
|
||||
|
||||
public void setMinTemp(Double minTemp) {
|
||||
this.minTemp = minTemp;
|
||||
}
|
||||
|
||||
|
||||
public Double getMaxTemp() {
|
||||
return maxTemp;
|
||||
}
|
||||
|
||||
|
||||
public void setMaxTemp(Double maxTemp) {
|
||||
this.maxTemp = maxTemp;
|
||||
}
|
||||
|
||||
|
||||
public Double getPhMax() {
|
||||
return phMax;
|
||||
}
|
||||
|
||||
|
||||
public void setPhMax(Double phMax) {
|
||||
this.phMax = phMax;
|
||||
}
|
||||
|
||||
|
||||
public Double getPhMin() {
|
||||
return phMin;
|
||||
}
|
||||
|
||||
|
||||
public void setPhMin(Double phMin) {
|
||||
this.phMin = phMin;
|
||||
}
|
||||
|
||||
|
||||
public boolean isAllNull() {
|
||||
return allNull;
|
||||
}
|
||||
|
||||
|
||||
public void setAllNull(boolean allNull) {
|
||||
this.allNull = allNull;
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
package com.github.mdeluise.plantit.botanicalinfo.care;
|
||||
|
||||
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
|
||||
import org.modelmapper.ModelMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class PlantCareInfoDTOConverter extends AbstractDTOConverter<PlantCareInfo, PlantCareInfoDTO> {
|
||||
public PlantCareInfoDTOConverter(ModelMapper modelMapper) {
|
||||
super(modelMapper);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public PlantCareInfo convertFromDTO(PlantCareInfoDTO dto) {
|
||||
if (dto == null) {
|
||||
return new PlantCareInfo();
|
||||
}
|
||||
return modelMapper.map(dto, PlantCareInfo.class);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public PlantCareInfoDTO convertToDTO(PlantCareInfo data) {
|
||||
return modelMapper.map(data, PlantCareInfoDTO.class);
|
||||
}
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
package com.github.mdeluise.plantit.common;
|
||||
|
||||
import org.modelmapper.ModelMapper;
|
||||
|
||||
public abstract class AbstractDTOConverter<T, D> {
|
||||
protected final ModelMapper modelMapper;
|
||||
|
||||
|
||||
public AbstractDTOConverter(ModelMapper modelMapper) {
|
||||
this.modelMapper = modelMapper;
|
||||
}
|
||||
|
||||
|
||||
public abstract T convertFromDTO(D dto);
|
||||
|
||||
public abstract D convertToDTO(T data);
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
package com.github.mdeluise.plantit.common;
|
||||
|
||||
import com.github.mdeluise.plantit.authentication.User;
|
||||
import com.github.mdeluise.plantit.authentication.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class AuthenticatedUserService {
|
||||
private final UserService userService;
|
||||
|
||||
|
||||
@Autowired
|
||||
public AuthenticatedUserService(UserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
|
||||
public User getAuthenticatedUser() {
|
||||
final SecurityContext context = SecurityContextHolder.getContext();
|
||||
final Authentication authentication = context.getAuthentication();
|
||||
final String username = authentication.getName();
|
||||
return userService.get(username);
|
||||
}
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
package com.github.mdeluise.plantit.common;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(name = "API Response", description = "Represents a response from an API")
|
||||
public class MessageResponse {
|
||||
@Schema(description = "Message of the response", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String message;
|
||||
|
||||
|
||||
public MessageResponse(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
@ -1,97 +0,0 @@
|
||||
package com.github.mdeluise.plantit.diary;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import com.github.mdeluise.plantit.authentication.User;
|
||||
import com.github.mdeluise.plantit.diary.entry.DiaryEntry;
|
||||
import com.github.mdeluise.plantit.plant.Plant;
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
@Entity
|
||||
@Table(name = "diaries")
|
||||
public class Diary implements Serializable {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
private Long id;
|
||||
@OneToOne
|
||||
@JoinColumn(name = "target_id", nullable = false)
|
||||
private Plant target;
|
||||
@NotNull
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User owner;
|
||||
@OneToMany(mappedBy = "diary", cascade = CascadeType.ALL)
|
||||
private Set<DiaryEntry> entries = new HashSet<>();
|
||||
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
public Plant getTarget() {
|
||||
return target;
|
||||
}
|
||||
|
||||
|
||||
public void setTarget(Plant target) {
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
|
||||
public User getOwner() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
|
||||
public void setOwner(User owner) {
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
|
||||
public Set<DiaryEntry> getEntries() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
|
||||
public void setEntries(Set<DiaryEntry> entries) {
|
||||
this.entries = entries;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
final Diary diary = (Diary) o;
|
||||
return Objects.equals(id, diary.id) || Objects.equals(target, diary.target);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(id);
|
||||
}
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
package com.github.mdeluise.plantit.diary;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(name = "Diary", description = "Represents a diary.")
|
||||
public class DiaryDTO {
|
||||
@Schema(description = "ID of the diary.", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private Long id;
|
||||
@Schema(description = "Target ID of the diary.")
|
||||
private Long targetId;
|
||||
@Schema(description = "Owner ID of the diary.")
|
||||
private Long userId;
|
||||
@Schema(description = "Number of entities in the diary.", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private Long entriesCount;
|
||||
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
public Long getTargetId() {
|
||||
return targetId;
|
||||
}
|
||||
|
||||
|
||||
public void setTargetId(Long targetId) {
|
||||
this.targetId = targetId;
|
||||
}
|
||||
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
public Long getEntriesCount() {
|
||||
return entriesCount;
|
||||
}
|
||||
|
||||
|
||||
public void setEntriesCount(Long entriesCount) {
|
||||
this.entriesCount = entriesCount;
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
package com.github.mdeluise.plantit.diary;
|
||||
|
||||
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
|
||||
import org.modelmapper.ModelMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class DiaryDTOConverter extends AbstractDTOConverter<Diary, DiaryDTO> {
|
||||
@Autowired
|
||||
public DiaryDTOConverter(ModelMapper modelMapper) {
|
||||
super(modelMapper);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Diary convertFromDTO(DiaryDTO dto) {
|
||||
return modelMapper.map(dto, Diary.class);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public DiaryDTO convertToDTO(Diary data) {
|
||||
final DiaryDTO result = modelMapper.map(data, DiaryDTO.class);
|
||||
result.setEntriesCount((long) data.getEntries().size());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
package com.github.mdeluise.plantit.diary;
|
||||
|
||||
import com.github.mdeluise.plantit.authentication.User;
|
||||
import com.github.mdeluise.plantit.plant.Plant;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface DiaryRepository extends JpaRepository<Diary, Long> {
|
||||
Page<Diary> findAllByOwner(User user, Pageable pageable);
|
||||
|
||||
Optional<Diary> findByOwnerAndTarget(User user, Plant plant);
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
package com.github.mdeluise.plantit.diary;
|
||||
|
||||
import com.github.mdeluise.plantit.common.AuthenticatedUserService;
|
||||
import com.github.mdeluise.plantit.exception.ResourceNotFoundException;
|
||||
import com.github.mdeluise.plantit.plant.Plant;
|
||||
import com.github.mdeluise.plantit.plant.PlantService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class DiaryService {
|
||||
private final AuthenticatedUserService authenticatedUserService;
|
||||
private final DiaryRepository diaryRepository;
|
||||
private final PlantService plantService;
|
||||
|
||||
|
||||
@Autowired
|
||||
protected DiaryService(AuthenticatedUserService authenticatedUserService, DiaryRepository diaryRepository,
|
||||
PlantService plantService) {
|
||||
this.authenticatedUserService = authenticatedUserService;
|
||||
this.diaryRepository = diaryRepository;
|
||||
this.plantService = plantService;
|
||||
}
|
||||
|
||||
|
||||
public Page<Diary> getAll(Pageable pageable) {
|
||||
return diaryRepository.findAllByOwner(authenticatedUserService.getAuthenticatedUser(), pageable);
|
||||
}
|
||||
|
||||
|
||||
public Diary get(Long targetId) {
|
||||
final Plant plant = plantService.get(targetId);
|
||||
return diaryRepository.findByOwnerAndTarget(authenticatedUserService.getAuthenticatedUser(), plant)
|
||||
.orElseThrow(() -> new ResourceNotFoundException(targetId));
|
||||
}
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
package com.github.mdeluise.plantit.diary.entry;
|
||||
|
||||
import com.github.mdeluise.plantit.diary.Diary;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Entity
|
||||
@Table(name = "diary_entries")
|
||||
public class DiaryEntry {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
private Long id;
|
||||
@NotNull
|
||||
@Enumerated(EnumType.STRING)
|
||||
private DiaryEntryType type;
|
||||
private String note;
|
||||
@NotNull
|
||||
private Date date;
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "diary_id", nullable = false)
|
||||
private Diary diary;
|
||||
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
public DiaryEntryType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
|
||||
public void setType(DiaryEntryType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
|
||||
public String getNote() {
|
||||
return note;
|
||||
}
|
||||
|
||||
|
||||
public void setNote(String note) {
|
||||
this.note = note;
|
||||
}
|
||||
|
||||
|
||||
public Date getDate() {
|
||||
return date;
|
||||
}
|
||||
|
||||
|
||||
public void setDate(Date date) {
|
||||
this.date = date;
|
||||
}
|
||||
|
||||
|
||||
public Diary getDiary() {
|
||||
return diary;
|
||||
}
|
||||
|
||||
|
||||
public void setDiary(Diary diary) {
|
||||
this.diary = diary;
|
||||
}
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
package com.github.mdeluise.plantit.diary.entry;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import com.github.mdeluise.plantit.common.MessageResponse;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/diary/entry")
|
||||
public class DiaryEntryController {
|
||||
private final DiaryEntryService diaryEntryService;
|
||||
private final DiaryEntryDTOConverter diaryEntryDtoConverter;
|
||||
|
||||
|
||||
@Autowired
|
||||
public DiaryEntryController(DiaryEntryService diaryEntryService, DiaryEntryDTOConverter diaryEntryDtoConverter) {
|
||||
this.diaryEntryService = diaryEntryService;
|
||||
this.diaryEntryDtoConverter = diaryEntryDtoConverter;
|
||||
}
|
||||
|
||||
|
||||
@SuppressWarnings("ParameterNumber") // FIXME
|
||||
@GetMapping
|
||||
public ResponseEntity<Page<DiaryEntryDTO>> getAllEntries(
|
||||
@RequestParam(defaultValue = "0", required = false) Integer pageNo,
|
||||
@RequestParam(defaultValue = "25", required = false) Integer pageSize,
|
||||
@RequestParam(defaultValue = "date", required = false) String sortBy,
|
||||
@RequestParam(defaultValue = "DESC", required = false) Sort.Direction sortDir,
|
||||
@RequestParam(defaultValue = "", required = false) List<Long> plantIds,
|
||||
@RequestParam(defaultValue = "", required = false) List<String> eventTypes) {
|
||||
final Pageable pageable = PageRequest.of(pageNo, pageSize, sortDir, sortBy);
|
||||
final Page<DiaryEntry> result = diaryEntryService.getAll(pageable, plantIds, eventTypes);
|
||||
return ResponseEntity.ok(result.map(diaryEntryDtoConverter::convertToDTO));
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<DiaryEntryDTO> get(@PathVariable Long id) {
|
||||
final DiaryEntry result = diaryEntryService.get(id);
|
||||
return ResponseEntity.ok(diaryEntryDtoConverter.convertToDTO(result));
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/all/{diaryId}")
|
||||
public ResponseEntity<Page<DiaryEntryDTO>> getEntries(
|
||||
@RequestParam(defaultValue = "0", required = false) Integer pageNo,
|
||||
@RequestParam(defaultValue = "25", required = false) Integer pageSize,
|
||||
@RequestParam(defaultValue = "date", required = false) String sortBy,
|
||||
@RequestParam(defaultValue = "DESC", required = false) Sort.Direction sortDir,
|
||||
@PathVariable Long diaryId) {
|
||||
final Pageable pageable = PageRequest.of(pageNo, pageSize, sortDir, sortBy);
|
||||
final Page<DiaryEntry> result = diaryEntryService.getAll(diaryId, pageable);
|
||||
return ResponseEntity.ok(result.map(diaryEntryDtoConverter::convertToDTO));
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/type")
|
||||
public ResponseEntity<Collection<DiaryEntryType>> getEntryTypes() {
|
||||
final Collection<DiaryEntryType> result = diaryEntryService.getAllTypes();
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<DiaryEntryDTO> save(@RequestBody DiaryEntryDTO diaryEntryDTO) {
|
||||
final DiaryEntry result = diaryEntryService.save(diaryEntryDtoConverter.convertFromDTO(diaryEntryDTO));
|
||||
return ResponseEntity.ok(diaryEntryDtoConverter.convertToDTO(result));
|
||||
}
|
||||
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<DiaryEntryDTO> save(@PathVariable Long id, @RequestBody DiaryEntryDTO diaryEntryDTO) {
|
||||
final DiaryEntry result = diaryEntryService.update(id, diaryEntryDtoConverter.convertFromDTO(diaryEntryDTO));
|
||||
return ResponseEntity.ok(diaryEntryDtoConverter.convertToDTO(result));
|
||||
}
|
||||
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<MessageResponse> delete(@PathVariable Long id) {
|
||||
diaryEntryService.delete(id);
|
||||
return ResponseEntity.ok(new MessageResponse("Success"));
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/_count")
|
||||
public ResponseEntity<Long> count() {
|
||||
return ResponseEntity.ok(diaryEntryService.count());
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/{plantId}/_count")
|
||||
public ResponseEntity<Long> countByPlant(@PathVariable Long plantId) {
|
||||
return ResponseEntity.ok(diaryEntryService.count(plantId));
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/{plantId}/stats")
|
||||
public ResponseEntity<Collection<DiaryEntryStats>> getStats(@PathVariable Long plantId) {
|
||||
final Collection<DiaryEntryStats> result = diaryEntryService.getStats(plantId);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
package com.github.mdeluise.plantit.diary.entry;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(name = "Diary entry", description = "Represents an entry in the diary.")
|
||||
public class DiaryEntryDTO {
|
||||
@Schema(description = "ID of the diary entry.", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private Long id;
|
||||
@Schema(description = "Type of the diary entry.")
|
||||
private String type;
|
||||
@Schema(description = "Note attached to the diary entry.")
|
||||
private String note;
|
||||
@Schema(description = "Date of the diary entry.")
|
||||
private Date date;
|
||||
@Schema(description = "Diary ID of the entry.")
|
||||
private Long diaryId;
|
||||
@Schema(description = "ID of the tracked entity.", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private Long diaryTargetId;
|
||||
@JsonProperty("diaryTargetPersonalName")
|
||||
@Schema(description = "Personal name of the tracked entity.", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String diaryTargetInfoPersonalName;
|
||||
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
|
||||
public String getNote() {
|
||||
return note;
|
||||
}
|
||||
|
||||
|
||||
public void setNote(String note) {
|
||||
this.note = note;
|
||||
}
|
||||
|
||||
|
||||
public Date getDate() {
|
||||
return date;
|
||||
}
|
||||
|
||||
|
||||
public void setDate(Date date) {
|
||||
this.date = date;
|
||||
}
|
||||
|
||||
|
||||
public Long getDiaryId() {
|
||||
return diaryId;
|
||||
}
|
||||
|
||||
|
||||
public void setDiaryId(Long diaryId) {
|
||||
this.diaryId = diaryId;
|
||||
}
|
||||
|
||||
|
||||
public Long getDiaryTargetId() {
|
||||
return diaryTargetId;
|
||||
}
|
||||
|
||||
|
||||
public void setDiaryTargetId(Long diaryTargetId) {
|
||||
this.diaryTargetId = diaryTargetId;
|
||||
}
|
||||
|
||||
|
||||
public void setDiaryTargetInfoPersonalName(String diaryTargetInfoPersonalName) {
|
||||
this.diaryTargetInfoPersonalName = diaryTargetInfoPersonalName;
|
||||
}
|
||||
|
||||
|
||||
public String getDiaryTargetInfoPersonalName() {
|
||||
return diaryTargetInfoPersonalName;
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
package com.github.mdeluise.plantit.diary.entry;
|
||||
|
||||
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
|
||||
import org.modelmapper.ModelMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
public class DiaryEntryDTOConverter extends AbstractDTOConverter<DiaryEntry, DiaryEntryDTO> {
|
||||
public DiaryEntryDTOConverter(ModelMapper modelMapper) {
|
||||
super(modelMapper);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public DiaryEntry convertFromDTO(DiaryEntryDTO dto) {
|
||||
final DiaryEntry result = modelMapper.map(dto, DiaryEntry.class);
|
||||
if (result.getDate() == null) {
|
||||
result.setDate(new Date());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public DiaryEntryDTO convertToDTO(DiaryEntry data) {
|
||||
return modelMapper.map(data, DiaryEntryDTO.class);
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
package com.github.mdeluise.plantit.diary.entry;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import com.github.mdeluise.plantit.authentication.User;
|
||||
import com.github.mdeluise.plantit.diary.Diary;
|
||||
import com.github.mdeluise.plantit.plant.Plant;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface DiaryEntryRepository extends JpaRepository<DiaryEntry, Long> {
|
||||
Optional<DiaryEntry> findFirstByDiaryTargetAndTypeOrderByDateDesc(Plant target, DiaryEntryType type);
|
||||
|
||||
Page<DiaryEntry> findAllByDiaryOwner(User user, Pageable pageable);
|
||||
|
||||
Page<DiaryEntry> findAllByDiaryOwnerAndDiary(User user, Diary diary, Pageable pageable);
|
||||
|
||||
Long countByDiaryOwner(User user);
|
||||
|
||||
Long countByDiaryOwnerAndDiaryTargetId(User authenticatedUser, Long plantId);
|
||||
|
||||
Optional<DiaryEntry> findFirstByDiaryOwnerAndDiaryTargetIdAndTypeOrderByDateDesc(User user, Long id,
|
||||
DiaryEntryType diaryEntryType);
|
||||
}
|
||||
@ -1,166 +0,0 @@
|
||||
package com.github.mdeluise.plantit.diary.entry;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import com.github.mdeluise.plantit.authentication.User;
|
||||
import com.github.mdeluise.plantit.common.AuthenticatedUserService;
|
||||
import com.github.mdeluise.plantit.diary.Diary;
|
||||
import com.github.mdeluise.plantit.diary.DiaryService;
|
||||
import com.github.mdeluise.plantit.exception.ResourceNotFoundException;
|
||||
import com.github.mdeluise.plantit.exception.UnauthorizedException;
|
||||
import com.github.mdeluise.plantit.plant.Plant;
|
||||
import com.github.mdeluise.plantit.plant.PlantRepository;
|
||||
import com.github.mdeluise.plantit.plant.PlantService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class DiaryEntryService {
|
||||
private final AuthenticatedUserService authenticatedUserService;
|
||||
private final DiaryEntryRepository diaryEntryRepository;
|
||||
private final DiaryService diaryService;
|
||||
private final PlantService plantService;
|
||||
private final PlantRepository plantRepository;
|
||||
|
||||
|
||||
@Autowired
|
||||
public DiaryEntryService(AuthenticatedUserService authenticatedUserService,
|
||||
DiaryEntryRepository diaryEntryRepository, DiaryService diaryService,
|
||||
PlantService plantService, PlantRepository plantRepository) {
|
||||
this.authenticatedUserService = authenticatedUserService;
|
||||
this.diaryEntryRepository = diaryEntryRepository;
|
||||
this.diaryService = diaryService;
|
||||
this.plantService = plantService;
|
||||
this.plantRepository = plantRepository;
|
||||
}
|
||||
|
||||
|
||||
public Page<DiaryEntry> getAll(Pageable pageable, List<Long> plantIds, List<String> eventTypes) {
|
||||
checkPlantExistenceAndVisibility(plantIds);
|
||||
checkEventTypeExistence(eventTypes);
|
||||
|
||||
if (plantIds.isEmpty() && eventTypes.isEmpty()) {
|
||||
return diaryEntryRepository.findAllByDiaryOwner(authenticatedUserService.getAuthenticatedUser(), pageable);
|
||||
}
|
||||
|
||||
final Pageable pageableToUse = PageRequest.of(0, Math.max(1, count().intValue()), pageable.getSort());
|
||||
final List<DiaryEntry> filteredResult =
|
||||
diaryEntryRepository.findAllByDiaryOwner(
|
||||
authenticatedUserService.getAuthenticatedUser(), pageableToUse).stream()
|
||||
.filter(entry -> plantIds.isEmpty() || plantIds.contains(entry.getDiary().getTarget().getId()))
|
||||
.filter(entry -> eventTypes.isEmpty() || eventTypes.contains(entry.getType().name()))
|
||||
.toList();
|
||||
|
||||
final int start = (int) pageable.getOffset();
|
||||
final int end = Math.min(start + pageable.getPageSize(), filteredResult.size());
|
||||
return new PageImpl<>(filteredResult.subList(start, end), pageable, filteredResult.size());
|
||||
}
|
||||
|
||||
|
||||
private void checkEventTypeExistence(List<String> eventTypes) {
|
||||
eventTypes.forEach(DiaryEntryType::valueOf);
|
||||
}
|
||||
|
||||
|
||||
private void checkPlantExistenceAndVisibility(List<Long> plantIds) {
|
||||
plantIds.forEach(plantService::get);
|
||||
}
|
||||
|
||||
|
||||
public Page<DiaryEntry> getAll(Long diaryId, Pageable pageable) {
|
||||
final Diary diary = diaryService.get(diaryId);
|
||||
return diaryEntryRepository.findAllByDiaryOwnerAndDiary(
|
||||
authenticatedUserService.getAuthenticatedUser(), diary, pageable);
|
||||
}
|
||||
|
||||
|
||||
public Collection<DiaryEntryType> getAllTypes() {
|
||||
final DiaryEntryType[] result = DiaryEntryType.class.getEnumConstants();
|
||||
return List.of(result);
|
||||
}
|
||||
|
||||
|
||||
public DiaryEntry save(DiaryEntry diaryEntry) {
|
||||
final Diary diary = diaryService.get(diaryEntry.getDiary().getId());
|
||||
final User diaryOwner = diary.getOwner();
|
||||
if (!diaryOwner.equals(authenticatedUserService.getAuthenticatedUser())) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
diaryEntry.setDiary(diary); // if not, diaryEntry.diary has only the id and not other fields
|
||||
return diaryEntryRepository.save(diaryEntry);
|
||||
}
|
||||
|
||||
|
||||
public DiaryEntry get(Long id) {
|
||||
final DiaryEntry result =
|
||||
diaryEntryRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException(id));
|
||||
if (!result.getDiary().getOwner().equals(authenticatedUserService.getAuthenticatedUser())) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
public void delete(Long diaryEntryId) {
|
||||
final DiaryEntry toDelete = get(diaryEntryId);
|
||||
diaryEntryRepository.delete(toDelete);
|
||||
}
|
||||
|
||||
|
||||
public DiaryEntry update(Long id, DiaryEntry updated) {
|
||||
final DiaryEntry toUpdate = get(id);
|
||||
if (updated.getDiary() == null) {
|
||||
updated.setDiary(toUpdate.getDiary());
|
||||
}
|
||||
updated.getDiary().setOwner(authenticatedUserService.getAuthenticatedUser());
|
||||
updated.setId(id);
|
||||
return diaryEntryRepository.save(updated);
|
||||
}
|
||||
|
||||
|
||||
public Long count() {
|
||||
return diaryEntryRepository.countByDiaryOwner(authenticatedUserService.getAuthenticatedUser());
|
||||
}
|
||||
|
||||
|
||||
public Long count(Long plantId) {
|
||||
diaryService.get(plantId);
|
||||
return diaryEntryRepository.countByDiaryOwnerAndDiaryTargetId(
|
||||
authenticatedUserService.getAuthenticatedUser(), plantId);
|
||||
}
|
||||
|
||||
|
||||
public Collection<DiaryEntryStats> getStats(Long plantId) {
|
||||
diaryService.get(plantId);
|
||||
final Collection<DiaryEntryStats> result = new HashSet<>();
|
||||
final User authenticatedUser = authenticatedUserService.getAuthenticatedUser();
|
||||
for (DiaryEntryType type : getAllTypes()) {
|
||||
final Optional<DiaryEntry> lastEvent =
|
||||
diaryEntryRepository.findFirstByDiaryOwnerAndDiaryTargetIdAndTypeOrderByDateDesc(authenticatedUser, plantId,
|
||||
type
|
||||
);
|
||||
lastEvent.ifPresent(
|
||||
diaryEntry -> result.add(new DiaryEntryStats(diaryEntry.getType(), diaryEntry.getDate())));
|
||||
}
|
||||
final List<DiaryEntryStats> sortedResult = new ArrayList<>(result.stream().toList());
|
||||
sortedResult.sort(Comparator.comparing(DiaryEntryStats::date));
|
||||
Collections.reverse(sortedResult);
|
||||
return sortedResult;
|
||||
}
|
||||
|
||||
|
||||
public Optional<DiaryEntry> getLast(Long plantId, DiaryEntryType type) {
|
||||
final Plant plant = plantRepository.findById(plantId).orElseThrow(() -> new ResourceNotFoundException(plantId));
|
||||
return diaryEntryRepository.findFirstByDiaryTargetAndTypeOrderByDateDesc(plant, type);
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
package com.github.mdeluise.plantit.diary.entry;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
public record DiaryEntryStats(
|
||||
DiaryEntryType type,
|
||||
Date date
|
||||
) { }
|
||||
@ -1,16 +0,0 @@
|
||||
package com.github.mdeluise.plantit.diary.entry;
|
||||
|
||||
public enum DiaryEntryType {
|
||||
SEEDING,
|
||||
WATERING,
|
||||
FERTILIZING,
|
||||
BIOSTIMULATING,
|
||||
MISTING,
|
||||
TRANSPLANTING,
|
||||
WATER_CHANGING,
|
||||
OBSERVATION,
|
||||
TREATMENT,
|
||||
PROPAGATING,
|
||||
PRUNING,
|
||||
REPOTTING,
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
package com.github.mdeluise.plantit.exception;
|
||||
|
||||
import com.github.mdeluise.plantit.exception.error.ErrorCode;
|
||||
import com.github.mdeluise.plantit.exception.error.ErrorMessage;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
@ControllerAdvice
|
||||
@ResponseBody
|
||||
public class ControllerExceptionHandler {
|
||||
|
||||
@ExceptionHandler(ResourceNotFoundException.class)
|
||||
public ResponseEntity<ErrorMessage> entityNotFoundException(ResourceNotFoundException ex) {
|
||||
final ErrorMessage message = new ErrorMessage(
|
||||
HttpStatus.NOT_FOUND.value(),
|
||||
ErrorCode.RESOURCE_NOT_FOUND,
|
||||
ex.getMessage()
|
||||
);
|
||||
return new ResponseEntity<>(message, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
|
||||
@ExceptionHandler(UnauthorizedException.class)
|
||||
public ResponseEntity<ErrorMessage> unauthorizedException(UnauthorizedException ex) {
|
||||
final ErrorMessage message = new ErrorMessage(
|
||||
HttpStatus.UNAUTHORIZED.value(),
|
||||
ErrorCode.USER_UNAUTHORIZED,
|
||||
ex.getMessage()
|
||||
);
|
||||
return new ResponseEntity<>(message, HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ErrorMessage> globalExceptionHandler(Exception ex) {
|
||||
final ErrorMessage message = new ErrorMessage(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR.value(),
|
||||
ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
ex.getMessage()
|
||||
);
|
||||
return new ResponseEntity<>(message, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
package com.github.mdeluise.plantit.exception;
|
||||
|
||||
public class DuplicatedSpeciesException extends RuntimeException {
|
||||
public DuplicatedSpeciesException(String species) {
|
||||
super(String.format("Species \"%s\" already exists in another botanical info", species));
|
||||
}
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
package com.github.mdeluise.plantit.exception;
|
||||
|
||||
public class InfoExtractionException extends RuntimeException {
|
||||
public InfoExtractionException(Exception e) {
|
||||
super("Error while extracting information: " + e.getMessage());
|
||||
}
|
||||
|
||||
|
||||
public InfoExtractionException(String message) {
|
||||
super("Error while extracting information: " + message);
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
package com.github.mdeluise.plantit.exception;
|
||||
|
||||
public class MaximumNumberOfUsersReachedExceptions extends RuntimeException {
|
||||
public MaximumNumberOfUsersReachedExceptions() {
|
||||
super("Maximum number of user reached.");
|
||||
}
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
package com.github.mdeluise.plantit.exception;
|
||||
|
||||
public class ResourceNotFoundException extends RuntimeException {
|
||||
|
||||
public ResourceNotFoundException(Class clazz, String field, String fieldValue) {
|
||||
super(String.format("Resource of type %s with %s %s not found", clazz.getSimpleName(), field, fieldValue));
|
||||
}
|
||||
|
||||
|
||||
public ResourceNotFoundException(Object id) {
|
||||
this("id", String.valueOf(id));
|
||||
}
|
||||
|
||||
|
||||
public ResourceNotFoundException(Class clazz, Object id) {
|
||||
this(clazz, "id", String.valueOf(id));
|
||||
}
|
||||
|
||||
|
||||
public ResourceNotFoundException(String field, String fieldValue) {
|
||||
this(Object.class, field, fieldValue);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String getMessage() {
|
||||
return super.getMessage();
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
package com.github.mdeluise.plantit.exception;
|
||||
|
||||
public class UnauthorizedException extends RuntimeException {
|
||||
public UnauthorizedException() {
|
||||
super("User not authorized to perform the action");
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
package com.github.mdeluise.plantit.exception.error;
|
||||
|
||||
public enum ErrorCode {
|
||||
RESOURCE_NOT_FOUND,
|
||||
USER_UNAUTHORIZED,
|
||||
INTERNAL_SERVER_ERROR,
|
||||
AUTHENTICATION_ERROR,
|
||||
TOO_MANY_REQUESTS,
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
package com.github.mdeluise.plantit.exception.error;
|
||||
|
||||
public record ErrorMessage(int statusCode, ErrorCode errorCode, String message) {
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image;
|
||||
|
||||
import com.github.mdeluise.plantit.botanicalinfo.BotanicalInfo;
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.DiscriminatorValue;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import jakarta.persistence.Transient;
|
||||
|
||||
@Entity
|
||||
@DiscriminatorValue("1")
|
||||
public class BotanicalInfoImage extends EntityImageImpl {
|
||||
@OneToOne(cascade = {CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH})
|
||||
@JoinColumn(name = "botanical_info_entity_id")
|
||||
private BotanicalInfo target;
|
||||
@Transient
|
||||
private byte[] content;
|
||||
|
||||
|
||||
public BotanicalInfoImage() {
|
||||
super();
|
||||
}
|
||||
|
||||
|
||||
public BotanicalInfo getTarget() {
|
||||
return target;
|
||||
}
|
||||
|
||||
|
||||
public void setTarget(BotanicalInfo target) {
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
|
||||
public byte[] getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
public void setContent(byte[] content) {
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
public interface EntityImage {
|
||||
String getId();
|
||||
|
||||
void setId(String id);
|
||||
|
||||
void setPath(String path);
|
||||
|
||||
String getPath();
|
||||
|
||||
void setUrl(String url);
|
||||
|
||||
String getUrl();
|
||||
|
||||
void setContentType(String type);
|
||||
|
||||
String getContentType();
|
||||
|
||||
void setCreateOn(Date createOn);
|
||||
|
||||
Date getCreateOn();
|
||||
}
|
||||
@ -1,107 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.persistence.DiscriminatorColumn;
|
||||
import jakarta.persistence.DiscriminatorType;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Inheritance;
|
||||
import jakarta.persistence.InheritanceType;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
|
||||
@Entity(name = "entity_images")
|
||||
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
|
||||
@DiscriminatorColumn(
|
||||
name = "image_type", discriminatorType = DiscriminatorType.INTEGER, columnDefinition = "TINYINT(1)"
|
||||
)
|
||||
public class EntityImageImpl implements EntityImage, Serializable {
|
||||
@Id
|
||||
@NotNull
|
||||
private String id;
|
||||
@Length(max = 100)
|
||||
private String description;
|
||||
@NotNull
|
||||
private Date createOn;
|
||||
@Length(max = 255)
|
||||
private String url;
|
||||
private String path;
|
||||
private String contentType;
|
||||
|
||||
|
||||
public EntityImageImpl() {
|
||||
this.id = UUID.randomUUID().toString();
|
||||
this.createOn = new Date();
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void setPath(String path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
|
||||
public void setContentType(String type) {
|
||||
this.contentType = type;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void setCreateOn(Date createOn) {
|
||||
this.createOn = createOn;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Date getCreateOn() {
|
||||
return createOn;
|
||||
}
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
public class ImageContentResponse implements Serializable {
|
||||
private byte[] content;
|
||||
private MediaType type;
|
||||
|
||||
|
||||
public ImageContentResponse(byte[] content, MediaType type) {
|
||||
this.content = content;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
|
||||
public byte[] getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
public void setContent(byte[] content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
|
||||
public MediaType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
|
||||
public void setType(MediaType type) {
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
@ -1,131 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
|
||||
import com.github.mdeluise.plantit.common.MessageResponse;
|
||||
import com.github.mdeluise.plantit.image.storage.ImageStorageService;
|
||||
import com.github.mdeluise.plantit.plant.Plant;
|
||||
import com.github.mdeluise.plantit.plant.PlantDTO;
|
||||
import com.github.mdeluise.plantit.plant.PlantDTOConverter;
|
||||
import com.github.mdeluise.plantit.plant.PlantService;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/image")
|
||||
public class ImageController {
|
||||
private final ImageStorageService imageStorageService;
|
||||
private final PlantService plantService;
|
||||
private final ImageDTOConverter imageDtoConverter;
|
||||
private final PlantDTOConverter plantDtoConverter;
|
||||
|
||||
|
||||
@Autowired
|
||||
public ImageController(ImageStorageService imageStorageService, PlantService plantService, ImageDTOConverter imageDtoConverter,
|
||||
PlantDTOConverter plantDtoConverter) {
|
||||
this.imageStorageService = imageStorageService;
|
||||
this.plantService = plantService;
|
||||
this.imageDtoConverter = imageDtoConverter;
|
||||
this.plantDtoConverter = plantDtoConverter;
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/plant/{plantId}/{imageId}")
|
||||
@Transactional
|
||||
public ResponseEntity<PlantDTO> updatePlantAvatarImageId(@PathVariable Long plantId, @PathVariable String imageId) {
|
||||
final Plant linkedEntity = plantService.get(plantId);
|
||||
final PlantImage newAvatarImage = (PlantImage) imageStorageService.get(imageId);
|
||||
linkedEntity.setAvatarImage(newAvatarImage);
|
||||
final Plant saved = plantService.update(linkedEntity.getId(), linkedEntity);
|
||||
return ResponseEntity.ok(plantDtoConverter.convertToDTO(saved));
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/metadata/{id}")
|
||||
public ResponseEntity<ImageDTO> get(@PathVariable("id") String id) {
|
||||
final EntityImage result = imageStorageService.get(id);
|
||||
return ResponseEntity.ok(imageDtoConverter.convertToDTO(result));
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/content/{id}")
|
||||
@SuppressWarnings("ReturnCount")
|
||||
public ResponseEntity<Resource> getContent(@PathVariable("id") String id) {
|
||||
try {
|
||||
final ImageContentResponse imageContent = imageStorageService.getImageContent(id);
|
||||
final HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(imageContent.getType());
|
||||
final ByteArrayResource resource = new ByteArrayResource(imageContent.getContent());
|
||||
return ResponseEntity.ok()
|
||||
.headers(headers)
|
||||
.contentLength(resource.contentLength())
|
||||
.body(resource);
|
||||
} catch (IOException e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/thumbnail/{id}")
|
||||
public ResponseEntity<byte[]> getThumbnail(@PathVariable("id") String id) {
|
||||
final byte[] result = Base64.getEncoder().encode(imageStorageService.getThumbnail(id));
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.IMAGE_JPEG);
|
||||
return new ResponseEntity<>(result, headers, HttpStatus.OK);
|
||||
}
|
||||
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<MessageResponse> delete(@PathVariable("id") String id) {
|
||||
imageStorageService.remove(id);
|
||||
return ResponseEntity.ok(new MessageResponse("Success"));
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/entity/{id}")
|
||||
public ResponseEntity<String> saveEntityImage(@RequestParam("image") MultipartFile file,
|
||||
@PathVariable("id") Long entityId,
|
||||
@RequestParam(value = "creationDate", required = false) String creationDateStr,
|
||||
@RequestParam(required = false) String description)
|
||||
throws ParseException {
|
||||
final Plant linkedEntity = plantService.get(entityId);
|
||||
final Date creationDate = creationDateStr != null ?
|
||||
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").parse(creationDateStr) :
|
||||
null;
|
||||
final EntityImage saved = imageStorageService.save(file, linkedEntity, creationDate, description);
|
||||
return ResponseEntity.ok(saved.getId());
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/entity/all/{id}")
|
||||
public ResponseEntity<Collection<String>> getAllImageIdsFromEntity(@PathVariable("id") Long id) {
|
||||
final Plant linkedEntity = plantService.get(id);
|
||||
final Collection<String> result = imageStorageService.getAllIds(linkedEntity);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/entity/_count")
|
||||
public ResponseEntity<Integer> countUserImage() {
|
||||
return ResponseEntity.ok(imageStorageService.count());
|
||||
}
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(name = "Image", description = "Represents a plant's image.")
|
||||
public class ImageDTO {
|
||||
@Schema(description = "ID of the image.", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String id;
|
||||
@Schema(description = "Creation date of the image.", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private Date createOn;
|
||||
@Schema(description = "Description of the image.", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String description;
|
||||
@Schema(description = "Target ID of the image.", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private Long targetId;
|
||||
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
public Date getCreateOn() {
|
||||
return createOn;
|
||||
}
|
||||
|
||||
|
||||
public void setCreateOn(Date createOn) {
|
||||
this.createOn = createOn;
|
||||
}
|
||||
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
|
||||
public Long getTargetId() {
|
||||
return targetId;
|
||||
}
|
||||
|
||||
|
||||
public void setTargetId(Long targetId) {
|
||||
this.targetId = targetId;
|
||||
}
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image;
|
||||
|
||||
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
|
||||
import org.modelmapper.ModelMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class ImageDTOConverter extends AbstractDTOConverter<EntityImage, ImageDTO> {
|
||||
@Autowired
|
||||
public ImageDTOConverter(ModelMapper modelMapper) {
|
||||
super(modelMapper);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public EntityImage convertFromDTO(ImageDTO dto) {
|
||||
return modelMapper.map(dto, EntityImageImpl.class);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public ImageDTO convertToDTO(EntityImage data) {
|
||||
return modelMapper.map(data, ImageDTO.class);
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ImageRepository extends JpaRepository<EntityImageImpl, String> {
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image;
|
||||
|
||||
// Marker interface
|
||||
public interface ImageTarget {
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Iterator;
|
||||
import javax.imageio.IIOImage;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageWriteParam;
|
||||
import javax.imageio.ImageWriter;
|
||||
import javax.imageio.stream.ImageOutputStream;
|
||||
|
||||
public class ImageUtility {
|
||||
public static byte[] compressImage(File data) throws IOException {
|
||||
return compressImage(data, 0.8f);
|
||||
}
|
||||
|
||||
|
||||
public static byte[] compressImage(File data, float quality) throws IOException {
|
||||
final String fileExtension = getFileExtension(data);
|
||||
final String filePath =
|
||||
String.format("%s/%s_compressed.%s", System.getProperty("java.io.tmpdir"), data.getName(), fileExtension);
|
||||
final File compressedImageFile = new File(filePath);
|
||||
final OutputStream os = new FileOutputStream(compressedImageFile);
|
||||
|
||||
final Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName(fileExtension);
|
||||
final ImageWriter writer = writers.next();
|
||||
|
||||
final ImageOutputStream ios = ImageIO.createImageOutputStream(os);
|
||||
writer.setOutput(ios);
|
||||
|
||||
final ImageWriteParam param = writer.getDefaultWriteParam();
|
||||
|
||||
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
||||
param.setCompressionQuality(quality);
|
||||
|
||||
final BufferedImage image = ImageIO.read(data);
|
||||
writer.write(null, new IIOImage(image, null, null), param);
|
||||
|
||||
os.close();
|
||||
ios.close();
|
||||
writer.dispose();
|
||||
|
||||
final FileInputStream fl = new FileInputStream(compressedImageFile);
|
||||
byte[] arr = new byte[(int) compressedImageFile.length()];
|
||||
fl.read(arr);
|
||||
fl.close();
|
||||
return arr;
|
||||
}
|
||||
|
||||
public static String getFileExtension(File file) {
|
||||
final String fileName = file.getName();
|
||||
final int lastDotIndex = fileName.lastIndexOf('.');
|
||||
if (lastDotIndex != -1 && lastDotIndex < fileName.length() - 1) {
|
||||
return fileName.substring(lastDotIndex + 1);
|
||||
} else {
|
||||
throw new UnsupportedOperationException(String.format("File %s does not have an extension", file.getName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image;
|
||||
|
||||
import com.github.mdeluise.plantit.plant.Plant;
|
||||
import jakarta.persistence.DiscriminatorValue;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToOne;
|
||||
|
||||
@Entity
|
||||
@DiscriminatorValue("2")
|
||||
public class PlantImage extends EntityImageImpl {
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "plant_entity_id")
|
||||
private Plant target;
|
||||
@OneToOne
|
||||
private Plant avatarOf;
|
||||
|
||||
|
||||
public PlantImage() {
|
||||
super();
|
||||
}
|
||||
|
||||
|
||||
public Plant getTarget() {
|
||||
return target;
|
||||
}
|
||||
|
||||
|
||||
public void setTarget(Plant target) {
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
|
||||
public Plant getAvatarOf() {
|
||||
return avatarOf;
|
||||
}
|
||||
|
||||
|
||||
public void setAvatarOf(Plant avatarOf) {
|
||||
this.avatarOf = avatarOf;
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.github.mdeluise.plantit.authentication.User;
|
||||
import com.github.mdeluise.plantit.plant.Plant;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
public interface PlantImageRepository extends JpaRepository<PlantImage, String> {
|
||||
// Bugged due to the inheritance
|
||||
//List<PlantImage> findAllByPlantImageTarget(Plant target);
|
||||
|
||||
@Query("SELECT i.id FROM PlantImage i WHERE i.target = ?1 ORDER BY i.createOn DESC")
|
||||
List<String> findAllIdsPlantByImageTargetOrderBySavedAtDesc(Plant target);
|
||||
|
||||
Integer countByTargetOwner(User user);
|
||||
}
|
||||
@ -1,328 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image.storage;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.Objects;
|
||||
|
||||
import com.github.mdeluise.plantit.botanicalinfo.BotanicalInfo;
|
||||
import com.github.mdeluise.plantit.common.AuthenticatedUserService;
|
||||
import com.github.mdeluise.plantit.exception.ResourceNotFoundException;
|
||||
import com.github.mdeluise.plantit.exception.UnauthorizedException;
|
||||
import com.github.mdeluise.plantit.image.BotanicalInfoImage;
|
||||
import com.github.mdeluise.plantit.image.EntityImage;
|
||||
import com.github.mdeluise.plantit.image.EntityImageImpl;
|
||||
import com.github.mdeluise.plantit.image.ImageContentResponse;
|
||||
import com.github.mdeluise.plantit.image.ImageRepository;
|
||||
import com.github.mdeluise.plantit.image.ImageTarget;
|
||||
import com.github.mdeluise.plantit.image.ImageUtility;
|
||||
import com.github.mdeluise.plantit.image.PlantImage;
|
||||
import com.github.mdeluise.plantit.image.PlantImageRepository;
|
||||
import com.github.mdeluise.plantit.plant.Plant;
|
||||
import com.github.mdeluise.plantit.plant.PlantAvatarMode;
|
||||
import com.github.mdeluise.plantit.plant.PlantRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.cache.annotation.Caching;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.FileSystemUtils;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Service
|
||||
@SuppressWarnings("ClassDataAbstractionCoupling") // FIXME
|
||||
public class FileSystemImageStorageService implements ImageStorageService {
|
||||
private final String rootLocation;
|
||||
private final ImageRepository imageRepository;
|
||||
private final PlantImageRepository plantImageRepository;
|
||||
private final PlantRepository plantRepository;
|
||||
private final int maxOriginImgSize;
|
||||
private final AuthenticatedUserService authenticatedUserService;
|
||||
private final Logger logger = LoggerFactory.getLogger(FileSystemImageStorageService.class);
|
||||
|
||||
|
||||
@Autowired
|
||||
@SuppressWarnings("ParameterNumber") //FIXME
|
||||
public FileSystemImageStorageService(@Value("${upload.location}") String rootLocation,
|
||||
ImageRepository imageRepository, PlantImageRepository plantImageRepository,
|
||||
PlantRepository plantRepository,
|
||||
@Value("${image.max_origin_size}") int maxOriginImgSize,
|
||||
AuthenticatedUserService authenticatedUserService) {
|
||||
this.rootLocation = rootLocation;
|
||||
this.imageRepository = imageRepository;
|
||||
this.plantImageRepository = plantImageRepository;
|
||||
this.plantRepository = plantRepository;
|
||||
this.maxOriginImgSize = maxOriginImgSize;
|
||||
this.authenticatedUserService = authenticatedUserService;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public EntityImage save(MultipartFile file, ImageTarget linkedEntity, Date creationDate, String description) {
|
||||
if (file.isEmpty()) {
|
||||
logger.error("File is empty");
|
||||
throw new StorageException("Failed to save empty file.");
|
||||
}
|
||||
String fileExtension;
|
||||
try {
|
||||
fileExtension = file.getContentType();
|
||||
} catch (NullPointerException e) {
|
||||
logger.error("Error while extract file extension from file with content type " + file.getContentType());
|
||||
throw new StorageException("Could not retrieve file information", e);
|
||||
}
|
||||
try {
|
||||
InputStream fileInputStream = file.getInputStream();
|
||||
if (file.getBytes().length > maxOriginImgSize) { // default to 10 MB
|
||||
logger.info(String.format("File size (%s byte) exceed %s MB, compressing...", file.getBytes().length,
|
||||
maxOriginImgSize
|
||||
));
|
||||
fileInputStream = new ByteArrayInputStream(ImageUtility.compressImage(file.getResource().getFile()));
|
||||
}
|
||||
return createImage(linkedEntity, creationDate, description, fileExtension, fileInputStream);
|
||||
} catch (IOException e) {
|
||||
logger.error("Error while reading file", e);
|
||||
throw new StorageException("Could not read provided file.", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private EntityImageImpl createImage(ImageTarget linkedEntity, Date creationDate, String description,
|
||||
String contentType, InputStream fileInputStream) {
|
||||
try {
|
||||
EntityImageImpl entityImage;
|
||||
if (linkedEntity instanceof BotanicalInfo b) {
|
||||
entityImage = new BotanicalInfoImage();
|
||||
((BotanicalInfoImage) entityImage).setTarget(b);
|
||||
} else if (linkedEntity instanceof Plant p) {
|
||||
entityImage = new PlantImage();
|
||||
((PlantImage) entityImage).setTarget(p);
|
||||
} else {
|
||||
throw new UnsupportedOperationException("Could not find suitable class for linkedEntity");
|
||||
}
|
||||
entityImage.setCreateOn(Objects.requireNonNullElseGet(creationDate, Date::new));
|
||||
entityImage.setContentType(contentType);
|
||||
final String fileExtension = MediaType.parseMediaType(contentType).getSubtype();
|
||||
final String fileName = String.format("%s/%s.%s", rootLocation, entityImage.getId(), fileExtension);
|
||||
final Path pathToFile = Path.of(fileName);
|
||||
createDestinationDirectoryIfNotExist(Path.of(rootLocation));
|
||||
Files.copy(fileInputStream, pathToFile);
|
||||
entityImage.setPath(String.format("%s/%s.%s", rootLocation, entityImage.getId(), fileExtension));
|
||||
entityImage.setDescription(description);
|
||||
return imageRepository.save(entityImage);
|
||||
} catch (IOException e) {
|
||||
logger.error("Error while saving file", e);
|
||||
throw new StorageException("Failed to save file.", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public EntityImage saveBotanicalInfoThumbnailImage(byte[] content, String contentType, BotanicalInfo linkedEntity) {
|
||||
return createImage(linkedEntity, new Date(), null, contentType, new ByteArrayInputStream(content));
|
||||
}
|
||||
|
||||
|
||||
private void createDestinationDirectoryIfNotExist(Path destinationPath) throws IOException {
|
||||
if (Files.exists(destinationPath)) {
|
||||
return;
|
||||
}
|
||||
logger.debug("Directory {} does not exist, creating it...", destinationPath);
|
||||
Files.createDirectories(destinationPath);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public EntityImage save(String url, ImageTarget linkedEntity) throws MalformedURLException {
|
||||
if (!(linkedEntity instanceof BotanicalInfo)) {
|
||||
throw new UnsupportedOperationException("URL images can be linked only to Botanical Info entities");
|
||||
}
|
||||
try {
|
||||
new URL(url).toURI();
|
||||
} catch (URISyntaxException e) {
|
||||
logger.error("Provided URL not correct", e);
|
||||
throw new MalformedURLException(e.getMessage());
|
||||
}
|
||||
final BotanicalInfoImage entityImage = new BotanicalInfoImage();
|
||||
entityImage.setTarget((BotanicalInfo) linkedEntity);
|
||||
entityImage.setUrl(url);
|
||||
return imageRepository.save(entityImage);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public EntityImage clone(String id, ImageTarget linkedEntity) {
|
||||
final EntityImage toClone = get(id);
|
||||
EntityImageImpl entityImage;
|
||||
if (linkedEntity instanceof BotanicalInfo b) {
|
||||
entityImage = new BotanicalInfoImage();
|
||||
((BotanicalInfoImage) entityImage).setTarget(b);
|
||||
} else if (linkedEntity instanceof Plant p) {
|
||||
entityImage = new PlantImage();
|
||||
((PlantImage) entityImage).setTarget(p);
|
||||
} else {
|
||||
throw new UnsupportedOperationException("Could not find suitable class for linkedEntity");
|
||||
}
|
||||
entityImage.setUrl(toClone.getUrl());
|
||||
if (toClone.getPath() != null) {
|
||||
final String resultPath = toClone.getPath().replace(toClone.getId(), entityImage.getId());
|
||||
try {
|
||||
Files.copy(Path.of(toClone.getPath()), Path.of(resultPath));
|
||||
} catch (IOException e) {
|
||||
logger.error("Error while saving file", e);
|
||||
throw new StorageException("Failed to save file.", e);
|
||||
}
|
||||
}
|
||||
return imageRepository.save(entityImage);
|
||||
}
|
||||
|
||||
|
||||
@Cacheable(value = "image", key = "{#id}")
|
||||
@Override
|
||||
public EntityImage get(String id) {
|
||||
final EntityImageImpl result =
|
||||
imageRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException(id));
|
||||
if (result instanceof PlantImage p &&
|
||||
p.getTarget().getOwner() != authenticatedUserService.getAuthenticatedUser()) {
|
||||
logger.warn("User not authorized to operate on image " + id);
|
||||
throw new UnauthorizedException();
|
||||
} else if (result instanceof BotanicalInfoImage b &&
|
||||
!b.getTarget().isAccessibleToUser(authenticatedUserService.getAuthenticatedUser())) {
|
||||
logger.warn("User not authorized to operate on image " + id);
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@Cacheable(value = "image-content", key = "{#id}")
|
||||
public ImageContentResponse getImageContent(String id) throws IOException {
|
||||
get(id);
|
||||
return getImageContentInternal(id);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public ImageContentResponse getImageContentInternal(String id) throws IOException {
|
||||
final EntityImageImpl image =
|
||||
imageRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException(id));
|
||||
if (image.getUrl() != null) {
|
||||
try {
|
||||
final RestTemplate restTemplate = new RestTemplate();
|
||||
final ResponseEntity<byte[]> response = restTemplate.getForEntity(new URI(image.getUrl()), byte[].class);
|
||||
|
||||
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
|
||||
final MediaType contentType = restTemplate.headForHeaders(new URI(image.getUrl())).getContentType();
|
||||
return new ImageContentResponse(response.getBody(), contentType);
|
||||
} else {
|
||||
throw new IOException("Failed to retrieve image content from URL: " + image.getUrl());
|
||||
}
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IOException("Invalid image URL: " + image.getUrl(), e);
|
||||
}
|
||||
} else {
|
||||
final MediaType contentType = MediaType.parseMediaType(image.getContentType());
|
||||
final Path imagePath = Paths.get(rootLocation + "/" + image.getId() + "." + contentType.getSubtype());
|
||||
final byte[] imageBytes = Files.readAllBytes(imagePath);
|
||||
return new ImageContentResponse(imageBytes, contentType);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Cacheable(value = "thumbnail", key = "{#id}")
|
||||
@Override
|
||||
public byte[] getThumbnail(String id) {
|
||||
final EntityImage getContentFrom = get(id);
|
||||
if (getContentFrom.getPath() == null) {
|
||||
logger.warn("Trying to read an image that has no content but only URL");
|
||||
throw new UnsupportedOperationException(String.format("Image with id %s has no content (only URL)", id));
|
||||
}
|
||||
try {
|
||||
final File entityImageFile = new File(getContentFrom.getPath());
|
||||
if (!entityImageFile.exists() || !entityImageFile.canRead()) {
|
||||
logger.error("Error while reading image " + id + " permission deny");
|
||||
throw new StorageFileNotFoundException("Could not read image with id: " + id);
|
||||
}
|
||||
return ImageUtility.compressImage(entityImageFile, .2f);
|
||||
} catch (IOException e) {
|
||||
logger.error("Error while reading image " + id, e);
|
||||
throw new StorageFileNotFoundException("Could not read image with id: " + id, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@CacheEvict(cacheNames = {"image", "thumbnail", "image-content"}, allEntries = true)
|
||||
@Override
|
||||
public void removeAll() {
|
||||
FileSystemUtils.deleteRecursively(Path.of(rootLocation).toFile());
|
||||
imageRepository.deleteAll();
|
||||
}
|
||||
|
||||
|
||||
@Caching(
|
||||
evict = {
|
||||
@CacheEvict(cacheNames = {"image", "thumbnail", "image-content"}, key = "{#id}"), @CacheEvict(
|
||||
value = "plants", allEntries = true, beforeInvocation = true,
|
||||
condition = "@plantImageRepository.findById(#id).present and " +
|
||||
"@plantImageRepository.findById(#id).get().avatarOf != null"
|
||||
)
|
||||
}
|
||||
)
|
||||
@Override
|
||||
// FIXME plantImageRepository and PlantImage should not be used,
|
||||
// maybe DeletePlantImageEntityAndFileCollaborator collaborator?
|
||||
public void remove(String id) {
|
||||
final EntityImage entityToDelete =
|
||||
imageRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException(id));
|
||||
if (entityToDelete instanceof PlantImage p && p.getAvatarOf() != null) {
|
||||
final Plant toUpdate = p.getAvatarOf();
|
||||
toUpdate.setAvatarImage(null);
|
||||
toUpdate.setAvatarMode(PlantAvatarMode.NONE);
|
||||
plantRepository.save(toUpdate);
|
||||
p.setAvatarOf(null);
|
||||
plantImageRepository.save(p);
|
||||
}
|
||||
final String entityImagePath = get(id).getPath();
|
||||
if (entityImagePath != null) {
|
||||
final File toRemove = new File(entityImagePath);
|
||||
boolean isToRemove = true;
|
||||
if (!toRemove.exists() || !toRemove.canRead()) {
|
||||
logger.error("Error while reading image {}. Deleting it only from DB", id);
|
||||
isToRemove = false;
|
||||
}
|
||||
if (isToRemove && !toRemove.delete()) {
|
||||
logger.error("Error while removing image {}", id);
|
||||
throw new StorageException("Could not remove image with id " + id);
|
||||
}
|
||||
}
|
||||
imageRepository.deleteById(id);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Collection<String> getAllIds(ImageTarget linkedEntity) {
|
||||
return plantImageRepository.findAllIdsPlantByImageTargetOrderBySavedAtDesc((Plant) linkedEntity);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public int count() {
|
||||
return plantImageRepository.countByTargetOwner(authenticatedUserService.getAuthenticatedUser());
|
||||
}
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image.storage;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.MalformedURLException;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
|
||||
import com.github.mdeluise.plantit.botanicalinfo.BotanicalInfo;
|
||||
import com.github.mdeluise.plantit.image.EntityImage;
|
||||
import com.github.mdeluise.plantit.image.ImageContentResponse;
|
||||
import com.github.mdeluise.plantit.image.ImageTarget;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface ImageStorageService {
|
||||
EntityImage save(MultipartFile file, ImageTarget linkedEntity, Date creationDate, String description);
|
||||
|
||||
EntityImage saveBotanicalInfoThumbnailImage(byte[] content, String contentType, BotanicalInfo linkedEntity);
|
||||
|
||||
EntityImage save(String url, ImageTarget linkedEntity) throws MalformedURLException;
|
||||
|
||||
EntityImage clone(String id, ImageTarget linkedEntity);
|
||||
|
||||
EntityImage get(String id);
|
||||
|
||||
ImageContentResponse getImageContent(String id) throws IOException;
|
||||
|
||||
ImageContentResponse getImageContentInternal(String id) throws IOException;
|
||||
|
||||
void remove(String id);
|
||||
|
||||
void removeAll();
|
||||
|
||||
Collection<String> getAllIds(ImageTarget linkedEntity);
|
||||
|
||||
int count();
|
||||
|
||||
byte[] getThumbnail(String id);
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image.storage;
|
||||
|
||||
public class StorageException extends RuntimeException {
|
||||
public StorageException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
|
||||
public StorageException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
package com.github.mdeluise.plantit.image.storage;
|
||||
|
||||
public class StorageFileNotFoundException extends StorageException {
|
||||
public StorageFileNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
|
||||
public StorageFileNotFoundException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification;
|
||||
|
||||
public class NotifyException extends Exception {
|
||||
public NotifyException(Throwable cause) {
|
||||
super("Error while notify about the reminder", cause);
|
||||
}
|
||||
|
||||
public NotifyException(String cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.console;
|
||||
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcher;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfig;
|
||||
import com.github.mdeluise.plantit.reminder.Reminder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class ConsoleNotificationDispatcher implements NotificationDispatcher {
|
||||
|
||||
@Override
|
||||
public void notifyReminder(Reminder reminder) {
|
||||
final String message = String.format(
|
||||
"[reminder for user %s] Time to care care for %s, action required: %s",
|
||||
reminder.getTarget().getOwner().getUsername(),
|
||||
reminder.getTarget().getInfo().getPersonalName(),
|
||||
reminder.getAction()
|
||||
);
|
||||
System.out.println(message);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public NotificationDispatcherName getName() {
|
||||
return NotificationDispatcherName.CONSOLE;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void loadConfig(NotificationDispatcherConfig config) {
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void initConfig() {
|
||||
}
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.dispatcher;
|
||||
|
||||
import com.github.mdeluise.plantit.notification.NotifyException;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfig;
|
||||
import com.github.mdeluise.plantit.reminder.Reminder;
|
||||
|
||||
public interface NotificationDispatcher {
|
||||
void notifyReminder(Reminder reminder) throws NotifyException;
|
||||
|
||||
NotificationDispatcherName getName();
|
||||
|
||||
boolean isEnabled();
|
||||
|
||||
void loadConfig(NotificationDispatcherConfig config);
|
||||
|
||||
void initConfig();
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.dispatcher;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
|
||||
import com.github.mdeluise.plantit.common.MessageResponse;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.config.AbstractNotificationDispatcherConfig;
|
||||
import com.github.mdeluise.plantit.notification.gotify.GotifyNotificationDispatcherConfig;
|
||||
import com.github.mdeluise.plantit.notification.gotify.GotifyNotificationDispatcherConfigDTO;
|
||||
import com.github.mdeluise.plantit.notification.gotify.GotifyNotificationDispatcherDTOConverter;
|
||||
import com.github.mdeluise.plantit.notification.ntfy.NtfyNotificationDispatcherConfig;
|
||||
import com.github.mdeluise.plantit.notification.ntfy.NtfyNotificationDispatcherConfigDTO;
|
||||
import com.github.mdeluise.plantit.notification.ntfy.NtfyNotificationDispatcherDTOConverter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/notification-dispatcher")
|
||||
@Tag(name = "Notifications", description = "Endpoints for notifications management")
|
||||
public class NotificationDispatcherController {
|
||||
private final NotificationDispatcherService notificationDispatcherService;
|
||||
private final NtfyNotificationDispatcherDTOConverter ntfyNotificationDispatcherDTOConverter;
|
||||
private final GotifyNotificationDispatcherDTOConverter gotifyNotificationDispatcherDTOConverter;
|
||||
|
||||
|
||||
@Autowired
|
||||
public NotificationDispatcherController(NotificationDispatcherService notificationDispatcherService,
|
||||
NtfyNotificationDispatcherDTOConverter ntfyNotificationDispatcherDTOConverter,
|
||||
GotifyNotificationDispatcherDTOConverter gotifyNotificationDispatcherDTOConverter) {
|
||||
this.notificationDispatcherService = notificationDispatcherService;
|
||||
this.ntfyNotificationDispatcherDTOConverter = ntfyNotificationDispatcherDTOConverter;
|
||||
this.gotifyNotificationDispatcherDTOConverter = gotifyNotificationDispatcherDTOConverter;
|
||||
}
|
||||
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Collection<NotificationDispatcherName>> getUserEnabled() {
|
||||
final Collection<NotificationDispatcherName> result =
|
||||
notificationDispatcherService.getNotificationDispatchersForUser();
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
|
||||
@PutMapping
|
||||
public ResponseEntity<MessageResponse> setUserEnabled(@RequestBody Set<NotificationDispatcherName> toEnable) {
|
||||
notificationDispatcherService.setNotificationDispatchersForUser(toEnable);
|
||||
return ResponseEntity.ok(new MessageResponse("Success"));
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/config/ntfy")
|
||||
public NtfyNotificationDispatcherConfigDTO getNtfyConfig() {
|
||||
final NtfyNotificationDispatcherConfig result =
|
||||
(NtfyNotificationDispatcherConfig) notificationDispatcherService.getUserConfig(NotificationDispatcherName.NTFY)
|
||||
.orElse(new NtfyNotificationDispatcherConfig());
|
||||
return ntfyNotificationDispatcherDTOConverter.convertToDTO(result);
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/config/ntfy")
|
||||
public ResponseEntity<MessageResponse> setNtfyConfig(@RequestBody NtfyNotificationDispatcherConfigDTO config) {
|
||||
final AbstractNotificationDispatcherConfig toSave = ntfyNotificationDispatcherDTOConverter.convertFromDTO(config);
|
||||
notificationDispatcherService.setUserConfig(NotificationDispatcherName.NTFY, toSave);
|
||||
return ResponseEntity.ok(new MessageResponse("Success"));
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/config/gotify")
|
||||
public GotifyNotificationDispatcherConfigDTO getGotifyConfig() {
|
||||
final GotifyNotificationDispatcherConfig result =
|
||||
(GotifyNotificationDispatcherConfig) notificationDispatcherService.getUserConfig(NotificationDispatcherName.GOTIFY)
|
||||
.orElse(new GotifyNotificationDispatcherConfig());
|
||||
return gotifyNotificationDispatcherDTOConverter.convertToDTO(result);
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/config/gotify")
|
||||
public ResponseEntity<MessageResponse> setGotifyConfig(@RequestBody GotifyNotificationDispatcherConfigDTO config) {
|
||||
final AbstractNotificationDispatcherConfig toSave = gotifyNotificationDispatcherDTOConverter.convertFromDTO(config);
|
||||
notificationDispatcherService.setUserConfig(NotificationDispatcherName.GOTIFY, toSave);
|
||||
return ResponseEntity.ok(new MessageResponse("Success"));
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.dispatcher;
|
||||
|
||||
public enum NotificationDispatcherName {
|
||||
CONSOLE,
|
||||
EMAIL,
|
||||
GOTIFY,
|
||||
NTFY
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.dispatcher;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.github.mdeluise.plantit.authentication.User;
|
||||
import com.github.mdeluise.plantit.authentication.UserService;
|
||||
import com.github.mdeluise.plantit.common.AuthenticatedUserService;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.config.AbstractNotificationDispatcherConfig;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfigImplRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class NotificationDispatcherService {
|
||||
private final AuthenticatedUserService authenticatedUserService;
|
||||
private final UserService userService;
|
||||
private final List<NotificationDispatcher> notificationDispatchers;
|
||||
private final NotificationDispatcherConfigImplRepository notificationDispatcherConfigImplRepository;
|
||||
|
||||
|
||||
@Autowired
|
||||
public NotificationDispatcherService(AuthenticatedUserService authenticatedUserService, UserService userService,
|
||||
List<NotificationDispatcher> listNotificationDispatchers,
|
||||
NotificationDispatcherConfigImplRepository notificationDispatcherConfigImplRepository) {
|
||||
this.authenticatedUserService = authenticatedUserService;
|
||||
this.userService = userService;
|
||||
this.notificationDispatchers = listNotificationDispatchers;
|
||||
this.notificationDispatcherConfigImplRepository = notificationDispatcherConfigImplRepository;
|
||||
}
|
||||
|
||||
|
||||
public void setNotificationDispatchersForUser(Set<NotificationDispatcherName> notificationDispatchers) {
|
||||
final User authenticatedUser = authenticatedUserService.getAuthenticatedUser();
|
||||
authenticatedUser.setNotificationDispatchers(notificationDispatchers);
|
||||
userService.save(authenticatedUser);
|
||||
}
|
||||
|
||||
|
||||
public Collection<NotificationDispatcherName> getNotificationDispatchersForUser() {
|
||||
final User authenticatedUser = authenticatedUserService.getAuthenticatedUser();
|
||||
return authenticatedUser.getNotificationDispatchers();
|
||||
}
|
||||
|
||||
|
||||
public Collection<NotificationDispatcherName> getAvailableNotificationDispatchers() {
|
||||
return notificationDispatchers.stream()
|
||||
.filter(NotificationDispatcher::isEnabled)
|
||||
.map(NotificationDispatcher::getName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
|
||||
public Optional<AbstractNotificationDispatcherConfig> getUserConfig(NotificationDispatcherName name) {
|
||||
return notificationDispatcherConfigImplRepository.findByServiceAndUser(
|
||||
name, authenticatedUserService.getAuthenticatedUser());
|
||||
}
|
||||
|
||||
|
||||
public void setUserConfig(NotificationDispatcherName name, AbstractNotificationDispatcherConfig updated) {
|
||||
final User authenticatedUser = authenticatedUserService.getAuthenticatedUser();
|
||||
final Optional<AbstractNotificationDispatcherConfig> existingConfig =
|
||||
notificationDispatcherConfigImplRepository.findByServiceAndUser(name, authenticatedUser);
|
||||
AbstractNotificationDispatcherConfig toSave;
|
||||
if (existingConfig.isPresent()) {
|
||||
existingConfig.get().update(updated);
|
||||
toSave = existingConfig.get();
|
||||
} else {
|
||||
updated.setUser(authenticatedUser);
|
||||
updated.setServiceName(name);
|
||||
toSave = updated;
|
||||
}
|
||||
notificationDispatcherConfigImplRepository.save(toSave);
|
||||
}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.dispatcher.config;
|
||||
|
||||
import com.github.mdeluise.plantit.authentication.User;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Inheritance;
|
||||
import jakarta.persistence.InheritanceType;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
@Entity
|
||||
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
|
||||
public abstract class AbstractNotificationDispatcherConfig implements NotificationDispatcherConfig {
|
||||
@Id
|
||||
@GeneratedValue
|
||||
private Long id;
|
||||
@NotNull
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
@NotNull
|
||||
@Enumerated(EnumType.STRING)
|
||||
private NotificationDispatcherName service;
|
||||
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public NotificationDispatcherName getServiceName() {
|
||||
return service;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void setServiceName(NotificationDispatcherName name) {
|
||||
this.service = name;
|
||||
}
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.dispatcher.config;
|
||||
|
||||
import com.github.mdeluise.plantit.authentication.User;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
|
||||
|
||||
public interface NotificationDispatcherConfig {
|
||||
User getUser();
|
||||
|
||||
void setUser(User user);
|
||||
|
||||
NotificationDispatcherName getServiceName();
|
||||
|
||||
void setServiceName(NotificationDispatcherName name);
|
||||
|
||||
void update(NotificationDispatcherConfig updated);
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.dispatcher.config;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import com.github.mdeluise.plantit.authentication.User;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface NotificationDispatcherConfigImplRepository extends JpaRepository<AbstractNotificationDispatcherConfig, Long> {
|
||||
Optional<AbstractNotificationDispatcherConfig> findByServiceAndUser(NotificationDispatcherName service, User user);
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.email;
|
||||
|
||||
public class EmailException extends Exception {
|
||||
public EmailException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
@ -1,243 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.email;
|
||||
|
||||
import com.github.mdeluise.plantit.notification.NotifyException;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcher;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfig;
|
||||
import com.github.mdeluise.plantit.notification.otp.OtpService;
|
||||
import com.github.mdeluise.plantit.notification.password.TemporaryPasswordService;
|
||||
import com.github.mdeluise.plantit.reminder.Reminder;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.Transport;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.apache.logging.log4j.util.Strings;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
|
||||
@Service
|
||||
public class EmailService implements NotificationDispatcher {
|
||||
private final JavaMailSender emailSender;
|
||||
private final SpringTemplateEngine templateEngine;
|
||||
private final OtpService otpService;
|
||||
private final TemporaryPasswordService temporaryPasswordService;
|
||||
private final String contactEmail;
|
||||
private final boolean enabled;
|
||||
private final String from;
|
||||
private final Logger logger = LoggerFactory.getLogger(EmailService.class);
|
||||
|
||||
|
||||
@SuppressWarnings("ParameterNumber") //FIXME
|
||||
@Autowired
|
||||
public EmailService(JavaMailSender emailSender, SpringTemplateEngine templateEngine, OtpService otpService,
|
||||
TemporaryPasswordService temporaryPasswordService,
|
||||
@Value("${server.owner.contact}") String contactEmail,
|
||||
@Value("${spring.mail.host}") String smtpHost,
|
||||
@Value("${spring.mail.username}") String from) throws EmailException {
|
||||
this.emailSender = emailSender;
|
||||
this.templateEngine = templateEngine;
|
||||
this.otpService = otpService;
|
||||
this.temporaryPasswordService = temporaryPasswordService;
|
||||
this.contactEmail = contactEmail;
|
||||
this.enabled = Strings.isNotEmpty(smtpHost);
|
||||
this.from = from;
|
||||
if (isEnabled()) {
|
||||
checkConnection();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void sendOtpMessage(String username, String to) throws EmailException {
|
||||
final MimeMessage message = emailSender.createMimeMessage();
|
||||
final MimeMessageHelper helper = createMessageHelper(message, to, "Welcome to Plant-It: Confirm Your Account");
|
||||
|
||||
final String otpCode = otpService.generateNew(to);
|
||||
final Context context = new Context();
|
||||
context.setVariable("supportEmail", contactEmail);
|
||||
context.setVariable("username", username);
|
||||
context.setVariable("otpCode", otpCode);
|
||||
|
||||
final String emailContent = getEmailContent("signup.html", context);
|
||||
try {
|
||||
helper.setText(emailContent, true);
|
||||
} catch (MessagingException e) {
|
||||
logger.error("Error while set text of the email", e);
|
||||
otpService.remove(otpCode);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
emailSender.send(message);
|
||||
}
|
||||
|
||||
|
||||
private String getEmailContent(String templateName, Context context) throws EmailException {
|
||||
try {
|
||||
return templateEngine.process(templateName, context);
|
||||
} catch (Exception e) {
|
||||
logger.error("Error while processing email template {}", templateName, e);
|
||||
throw new EmailException(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void checkConnection() throws EmailException {
|
||||
final JavaMailSenderImpl emailSenderImpl = (JavaMailSenderImpl) emailSender;
|
||||
final Session session = emailSenderImpl.getSession();
|
||||
try {
|
||||
final Transport transport = session.getTransport("smtp");
|
||||
transport.connect(emailSenderImpl.getHost(), emailSenderImpl.getUsername(), emailSenderImpl.getPassword());
|
||||
transport.close();
|
||||
logger.info("SMTP successfully connected.");
|
||||
} catch (MessagingException e) {
|
||||
logger.error("SMTP connection failed.", e);
|
||||
throw new EmailException(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void sendTemporaryPasswordMessage(String username, String to) throws EmailException {
|
||||
final MimeMessage message = emailSender.createMimeMessage();
|
||||
final MimeMessageHelper helper = createMessageHelper(message, to, "Password reset");
|
||||
|
||||
final String temporaryPassword = temporaryPasswordService.generateNew(username);
|
||||
final Context context = new Context();
|
||||
context.setVariable("supportEmail", contactEmail);
|
||||
context.setVariable("username", username);
|
||||
context.setVariable("temporaryPassword", temporaryPassword);
|
||||
|
||||
final String emailContent = getEmailContent("resetPsw.html", context);
|
||||
try {
|
||||
helper.setText(emailContent, true);
|
||||
} catch (MessagingException e) {
|
||||
logger.error("Error while set text of the email", e);
|
||||
otpService.remove(temporaryPassword);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
emailSender.send(message);
|
||||
}
|
||||
|
||||
|
||||
public void sendEmailChangeNotification(String username, String newEmail) throws EmailException {
|
||||
final MimeMessage message = emailSender.createMimeMessage();
|
||||
final MimeMessageHelper helper = createMessageHelper(message, newEmail, "Email change");
|
||||
|
||||
final Context context = new Context();
|
||||
context.setVariable("supportEmail", contactEmail);
|
||||
context.setVariable("username", username);
|
||||
context.setVariable("newEmail", newEmail);
|
||||
|
||||
final String emailContent = getEmailContent("emailChange.html", context);
|
||||
try {
|
||||
helper.setText(emailContent, true);
|
||||
} catch (MessagingException e) {
|
||||
logger.error("Error while set text of the email", e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
emailSender.send(message);
|
||||
}
|
||||
|
||||
|
||||
public void sendPasswordChangeNotification(String username, String email) throws EmailException {
|
||||
final MimeMessage message = emailSender.createMimeMessage();
|
||||
final MimeMessageHelper helper = createMessageHelper(message, email, "Password change");
|
||||
|
||||
final Context context = new Context();
|
||||
context.setVariable("supportEmail", contactEmail);
|
||||
context.setVariable("username", username);
|
||||
|
||||
final String emailContent = getEmailContent("passwordChange.html", context);
|
||||
|
||||
try {
|
||||
helper.setText(emailContent, true);
|
||||
} catch (MessagingException e) {
|
||||
logger.error("Error while set text of the email", e);
|
||||
throw new EmailException(e);
|
||||
}
|
||||
|
||||
emailSender.send(message);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void notifyReminder(Reminder reminder) throws NotifyException {
|
||||
final MimeMessage message = emailSender.createMimeMessage();
|
||||
final String email = reminder.getTarget().getOwner().getEmail();
|
||||
final String subject = "Reminder: Time to Care for " + reminder.getTarget().getInfo().getPersonalName();
|
||||
final MimeMessageHelper helper;
|
||||
try {
|
||||
helper = createMessageHelper(message, email, subject);
|
||||
} catch (EmailException e) {
|
||||
logger.error("Error while notify about the reminder", e);
|
||||
throw new NotifyException(e);
|
||||
}
|
||||
|
||||
final Context context = new Context();
|
||||
context.setVariable("username", reminder.getTarget().getOwner().getUsername());
|
||||
context.setVariable("plantName", reminder.getTarget().getInfo().getPersonalName());
|
||||
context.setVariable("action", reminder.getAction());
|
||||
final String emailContent;
|
||||
try {
|
||||
emailContent = getEmailContent("reminder.html", context);
|
||||
} catch (EmailException e) {
|
||||
logger.error("Error while get the email template content", e);
|
||||
throw new NotifyException(e);
|
||||
}
|
||||
|
||||
try {
|
||||
helper.setText(emailContent, true);
|
||||
} catch (MessagingException e) {
|
||||
logger.error("Error while set text of the email", e);
|
||||
throw new NotifyException(e);
|
||||
}
|
||||
|
||||
emailSender.send(message);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public NotificationDispatcherName getName() {
|
||||
return NotificationDispatcherName.EMAIL;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void loadConfig(NotificationDispatcherConfig config) {
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void initConfig() {
|
||||
}
|
||||
|
||||
|
||||
private MimeMessageHelper createMessageHelper(MimeMessage mimeMessage, String to, String subject)
|
||||
throws EmailException {
|
||||
final MimeMessageHelper helper;
|
||||
try {
|
||||
helper = new MimeMessageHelper(mimeMessage, true);
|
||||
helper.setTo(to);
|
||||
helper.setSubject(subject);
|
||||
helper.setFrom(from);
|
||||
} catch (MessagingException e) {
|
||||
logger.error("Error while setting mail to send", e);
|
||||
throw new EmailException(e);
|
||||
}
|
||||
return helper;
|
||||
}
|
||||
}
|
||||
@ -1,98 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.gotify;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import com.github.mdeluise.plantit.notification.NotifyException;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcher;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfig;
|
||||
import com.github.mdeluise.plantit.reminder.Reminder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class GotifyNotificationDispatcher implements NotificationDispatcher {
|
||||
private final boolean enabled;
|
||||
private String url;
|
||||
private String token;
|
||||
private final Logger logger = LoggerFactory.getLogger(GotifyNotificationDispatcher.class);
|
||||
|
||||
|
||||
public GotifyNotificationDispatcher(@Value("${server.notification.gotify.enabled}") boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
|
||||
public void notifyReminder(Reminder reminder) throws NotifyException {
|
||||
final HttpClient httpClient = HttpClient.newHttpClient();
|
||||
final URI uri = URI.create(url).resolve("/message?token=" + token);
|
||||
final String title = reminder.getTarget().getInfo().getPersonalName();
|
||||
final String message = "Time to take care of " + title + ", action required: " + reminder.getAction();
|
||||
final String body = String.format("{\"title\": \"%s\", \"message\": \"%s\"}", title, message);
|
||||
|
||||
final HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(uri)
|
||||
.header(HttpHeaders.CONTENT_TYPE, "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
|
||||
.build();
|
||||
|
||||
try {
|
||||
final HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
final int statusCode = response.statusCode();
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
throw new NotifyException("Failed to send notification. Response code: " + statusCode +
|
||||
" Response body: " + response.body());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new NotifyException(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public NotificationDispatcherName getName() {
|
||||
return NotificationDispatcherName.GOTIFY;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void loadConfig(NotificationDispatcherConfig config) {
|
||||
if (!(config instanceof GotifyNotificationDispatcherConfig gotifyConfig)) {
|
||||
throw new UnsupportedOperationException(
|
||||
"Configuration provided must be of type GotifyNotificationDispatcherConfig");
|
||||
}
|
||||
checkConfigParameters(gotifyConfig);
|
||||
url = gotifyConfig.getUrl();
|
||||
token = gotifyConfig.getToken();
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void initConfig() {
|
||||
url = null;
|
||||
token = null;
|
||||
}
|
||||
|
||||
|
||||
private void checkConfigParameters(GotifyNotificationDispatcherConfig gotifyConfig) {
|
||||
if (gotifyConfig.getUrl() == null || gotifyConfig.getToken() == null) {
|
||||
final String errorMsg = "Gotify url and token must be provided.";
|
||||
logger.error(errorMsg);
|
||||
throw new IllegalArgumentException(errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.gotify;
|
||||
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.config.AbstractNotificationDispatcherConfig;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfig;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "gotify_notification_configs")
|
||||
public class GotifyNotificationDispatcherConfig extends AbstractNotificationDispatcherConfig {
|
||||
private String url;
|
||||
private String token;
|
||||
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
public void setToken(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void update(NotificationDispatcherConfig updated) {
|
||||
if (!(updated instanceof GotifyNotificationDispatcherConfig)) {
|
||||
throw new UnsupportedOperationException("Updated class must be of type GotifyNotificationDispatcherConfig");
|
||||
}
|
||||
final GotifyNotificationDispatcherConfig gotifyUpdated = (GotifyNotificationDispatcherConfig) updated;
|
||||
this.setUrl(gotifyUpdated.getUrl());
|
||||
this.setToken(gotifyUpdated.getToken());
|
||||
}
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.gotify;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(name = "Gotify notification config", description = "Represents the gotify notifier configuration")
|
||||
public class GotifyNotificationDispatcherConfigDTO {
|
||||
@Schema(description = "id of the config", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String id;
|
||||
@Schema(description = "url of the gotify server")
|
||||
private String url;
|
||||
@Schema(description = "token for auth in the gotify server")
|
||||
private String token;
|
||||
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
public void setToken(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.gotify;
|
||||
|
||||
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
|
||||
import org.modelmapper.ModelMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class GotifyNotificationDispatcherDTOConverter extends
|
||||
AbstractDTOConverter<GotifyNotificationDispatcherConfig, GotifyNotificationDispatcherConfigDTO> {
|
||||
public GotifyNotificationDispatcherDTOConverter(ModelMapper modelMapper) {
|
||||
super(modelMapper);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public GotifyNotificationDispatcherConfig convertFromDTO(
|
||||
GotifyNotificationDispatcherConfigDTO dto) {
|
||||
return modelMapper.map(dto, GotifyNotificationDispatcherConfig.class);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public GotifyNotificationDispatcherConfigDTO convertToDTO(
|
||||
GotifyNotificationDispatcherConfig data) {
|
||||
return modelMapper.map(data, GotifyNotificationDispatcherConfigDTO.class);
|
||||
}
|
||||
}
|
||||
@ -1,123 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.ntfy;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
import com.github.mdeluise.plantit.notification.NotifyException;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcher;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfig;
|
||||
import com.github.mdeluise.plantit.reminder.Reminder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class NtfyNotificationDispatcher implements NotificationDispatcher {
|
||||
private final boolean enabled;
|
||||
private String url;
|
||||
private String topic;
|
||||
private String username;
|
||||
private String password;
|
||||
private String token;
|
||||
private final Logger logger = LoggerFactory.getLogger(NtfyNotificationDispatcher.class);
|
||||
|
||||
|
||||
public NtfyNotificationDispatcher(@Value("${server.notification.ntfy.enabled}") boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
|
||||
public void notifyReminder(Reminder reminder) throws NotifyException {
|
||||
final HttpClient httpClient = HttpClient.newHttpClient();
|
||||
final HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(url).resolve(topic))
|
||||
.header(HttpHeaders.CONTENT_TYPE, "application/json")
|
||||
.header("X-Title",
|
||||
reminder.getTarget().getInfo().getPersonalName())
|
||||
.header("X-Tags", "seedling");
|
||||
|
||||
final String requestBody =
|
||||
"Time to take care of " + reminder.getTarget().getInfo().getPersonalName() + ", action required: " +
|
||||
reminder.getAction();
|
||||
final HttpRequest.BodyPublisher bodyPublisher =
|
||||
HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8);
|
||||
requestBuilder.method("POST", bodyPublisher);
|
||||
|
||||
if (username != null && password != null) {
|
||||
final String credentials = username + ":" + password;
|
||||
final String basicAuth = "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes());
|
||||
requestBuilder.header(HttpHeaders.AUTHORIZATION, basicAuth);
|
||||
} else if (token != null) {
|
||||
requestBuilder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token);
|
||||
}
|
||||
|
||||
try {
|
||||
final HttpRequest httpRequest = requestBuilder.build();
|
||||
final HttpResponse<String> response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
int statusCode = response.statusCode();
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
throw new NotifyException("Failed to send notification. Response code: " + statusCode);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new NotifyException(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public NotificationDispatcherName getName() {
|
||||
return NotificationDispatcherName.NTFY;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void loadConfig(NotificationDispatcherConfig config) {
|
||||
if (!(config instanceof NtfyNotificationDispatcherConfig ntfyConfig)) {
|
||||
throw new UnsupportedOperationException(
|
||||
"Configuration provided must be of type NtfyNotificationDispatcherConfig");
|
||||
}
|
||||
checkConfigParameters(ntfyConfig);
|
||||
url = ntfyConfig.getUrl();
|
||||
topic = ntfyConfig.getTopic();
|
||||
username = ntfyConfig.getUsername();
|
||||
password = ntfyConfig.getPassword();
|
||||
token = ntfyConfig.getToken();
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void initConfig() {
|
||||
url = null;
|
||||
topic = null;
|
||||
username = null;
|
||||
password = null;
|
||||
token = null;
|
||||
}
|
||||
|
||||
|
||||
private void checkConfigParameters(NtfyNotificationDispatcherConfig ntfyConfig) {
|
||||
if (ntfyConfig.getUsername() != null ^ ntfyConfig.getPassword() != null) {
|
||||
final String errorMsg = "Either both username and password must be provided or both left empty.";
|
||||
logger.error(errorMsg);
|
||||
throw new IllegalArgumentException(errorMsg);
|
||||
}
|
||||
if (ntfyConfig.getUrl() == null || ntfyConfig.getTopic() == null) {
|
||||
final String errorMsg = "NTFY url and topic must be provided.";
|
||||
logger.error(errorMsg);
|
||||
throw new IllegalArgumentException(errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,80 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.ntfy;
|
||||
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.config.AbstractNotificationDispatcherConfig;
|
||||
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfig;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "ntfy_notification_configs")
|
||||
public class NtfyNotificationDispatcherConfig extends AbstractNotificationDispatcherConfig {
|
||||
private String url;
|
||||
private String topic;
|
||||
private String username;
|
||||
private String password;
|
||||
private String token;
|
||||
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
|
||||
public String getTopic() {
|
||||
return topic;
|
||||
}
|
||||
|
||||
|
||||
public void setTopic(String topic) {
|
||||
this.topic = topic;
|
||||
}
|
||||
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
public void setToken(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void update(NotificationDispatcherConfig updated) {
|
||||
if (!(updated instanceof NtfyNotificationDispatcherConfig)) {
|
||||
throw new UnsupportedOperationException("Updated class must be of type NtfyNotificationDispatcherConfig");
|
||||
}
|
||||
final NtfyNotificationDispatcherConfig ntfyUpdated = (NtfyNotificationDispatcherConfig) updated;
|
||||
this.setUrl(ntfyUpdated.getUrl());
|
||||
this.setTopic(ntfyUpdated.getTopic());
|
||||
this.setUsername(ntfyUpdated.getUsername());
|
||||
this.setPassword(ntfyUpdated.getPassword());
|
||||
this.setToken(ntfyUpdated.getToken());
|
||||
}
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.ntfy;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(name = "Ntfy notification config", description = "Represents the ntfy notifier configuration")
|
||||
public class NtfyNotificationDispatcherConfigDTO {
|
||||
@Schema(description = "id of the config", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String id;
|
||||
@Schema(description = "url of the ntfy server")
|
||||
private String url;
|
||||
@Schema(description = "topic of the ntfy server")
|
||||
private String topic;
|
||||
@Schema(description = "username for auth in the ntfy server")
|
||||
private String username;
|
||||
@Schema(description = "password for auth in the ntfy server")
|
||||
private String password;
|
||||
@Schema(description = "token for auth in the ntfy server")
|
||||
private String token;
|
||||
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
|
||||
public String getTopic() {
|
||||
return topic;
|
||||
}
|
||||
|
||||
|
||||
public void setTopic(String topic) {
|
||||
this.topic = topic;
|
||||
}
|
||||
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
public void setToken(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.ntfy;
|
||||
|
||||
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
|
||||
import org.modelmapper.ModelMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class NtfyNotificationDispatcherDTOConverter extends
|
||||
AbstractDTOConverter<NtfyNotificationDispatcherConfig, NtfyNotificationDispatcherConfigDTO> {
|
||||
public NtfyNotificationDispatcherDTOConverter(ModelMapper modelMapper) {
|
||||
super(modelMapper);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public NtfyNotificationDispatcherConfig convertFromDTO(NtfyNotificationDispatcherConfigDTO dto) {
|
||||
return modelMapper.map(dto, NtfyNotificationDispatcherConfig.class);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public NtfyNotificationDispatcherConfigDTO convertToDTO(NtfyNotificationDispatcherConfig data) {
|
||||
return modelMapper.map(data, NtfyNotificationDispatcherConfigDTO.class);
|
||||
}
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.otp;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.Objects;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
|
||||
@Entity
|
||||
@Table(name = "otp_codes")
|
||||
public class Otp {
|
||||
@Id
|
||||
@Length(min = 5, max = 70)
|
||||
private String email;
|
||||
@NotEmpty
|
||||
@Length(min = 5, max = 10)
|
||||
private String code;
|
||||
@NotNull
|
||||
private Date expiration;
|
||||
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
|
||||
public void setCode(String id) {
|
||||
this.code = id;
|
||||
}
|
||||
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
|
||||
public Date getExpiration() {
|
||||
return expiration;
|
||||
}
|
||||
|
||||
|
||||
public void setExpiration(Date expiration) {
|
||||
this.expiration = expiration;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
final Otp otp = (Otp) o;
|
||||
return Objects.equals(email, otp.email) && Objects.equals(code, otp.code) &&
|
||||
Objects.equals(expiration, otp.expiration);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(email, code, expiration);
|
||||
}
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
package com.github.mdeluise.plantit.notification.otp;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class OtpGenerator {
|
||||
private final String otpChars = "0123456789";
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
|
||||
public Otp generateOTP(int length, int expireMins, String email) {
|
||||
final Otp otp = new Otp();
|
||||
otp.setEmail(email);
|
||||
final StringBuilder otpCode = new StringBuilder(length);
|
||||
for (int i = 0; i < length; i++) {
|
||||
otpCode.append(otpChars.charAt(random.nextInt(otpChars.length())));
|
||||
}
|
||||
otp.setCode(otpCode.toString());
|
||||
final LocalDateTime currentDateTime = LocalDateTime.now();
|
||||
final LocalDateTime newDateTime = currentDateTime.plusMinutes(expireMins);
|
||||
otp.setExpiration(Date.from(newDateTime.atZone(ZoneId.systemDefault()).toInstant()));
|
||||
return otp;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user