refactor, add post edit and delete, merge APIs, add not found view
Signed-off-by: Nicolas Froger <nicolas@kektus.xyz>
This commit is contained in:
parent
2647ac244d
commit
efde8738a8
23 changed files with 803 additions and 343 deletions
|
|
@ -8,9 +8,9 @@ import jakarta.enterprise.context.ApplicationScoped;
|
|||
public class LocationConverter implements Converter<Location, LocationEntity> {
|
||||
@Override public Location toLeft(LocationEntity locationEntity) {
|
||||
return new Location().withId(locationEntity.id)
|
||||
.withLatitude(locationEntity.latitude)
|
||||
.withLongitude(locationEntity.longitude)
|
||||
.withTimestamp(locationEntity.timestamp);
|
||||
.withLatitude(locationEntity.latitude)
|
||||
.withLongitude(locationEntity.longitude)
|
||||
.withTimestamp(locationEntity.timestamp);
|
||||
}
|
||||
|
||||
@Override public LocationEntity toRight(Location location) {
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
package fr.kektus.summer2024.converters;
|
||||
|
||||
import fr.kektus.summer2024.data.model.Post;
|
||||
import fr.kektus.summer2024.domain.service.AssetService;
|
||||
import fr.kektus.summer2024.presentation.rest.PostApi;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
@ApplicationScoped
|
||||
public class PublicPostConverter {
|
||||
@Inject AssetService assetService;
|
||||
|
||||
public PostApi.PostDto toPostDto(Post post) {
|
||||
return new PostApi.PostDto().withId(post.id)
|
||||
.withDate(post.date)
|
||||
.withDescription(post.description)
|
||||
.withAssets(post.assets.stream()
|
||||
.map(assetService::getPresignedUrlForAsset)
|
||||
.toList())
|
||||
.withLocation(new PostApi.PostDto.Location().withCity(post.city)
|
||||
.withCountry(post.country)
|
||||
.withLat(post.latitude)
|
||||
.withLon(post.longitude));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package fr.kektus.summer2024.domain.entity;
|
||||
|
||||
import fr.kektus.summer2024.data.model.Asset;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import fr.kektus.summer2024.domain.entity.PresignedAsset;
|
|||
import io.minio.GetPresignedObjectUrlArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.PostPolicy;
|
||||
import io.minio.RemoveObjectArgs;
|
||||
import io.minio.errors.MinioException;
|
||||
import io.minio.http.Method;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
|
@ -62,4 +63,15 @@ public class AssetService {
|
|||
throw new InternalServerErrorException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteAsset(Asset asset) {
|
||||
try {
|
||||
minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(asset.filename).build());
|
||||
} catch (MinioException | InvalidKeyException | IOException | NoSuchAlgorithmException e) {
|
||||
throw new InternalServerErrorException(e);
|
||||
}
|
||||
|
||||
assetRepository.delete(asset);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import jakarta.inject.Inject;
|
|||
import jakarta.ws.rs.NotAuthorizedException;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.wildfly.common.Assert;
|
||||
|
||||
@ApplicationScoped
|
||||
public class AuthService {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package fr.kektus.summer2024.domain.service;
|
||||
|
||||
import fr.kektus.summer2024.data.model.Asset;
|
||||
import fr.kektus.summer2024.data.model.Post;
|
||||
import fr.kektus.summer2024.data.repository.AssetRepository;
|
||||
import fr.kektus.summer2024.data.repository.PostRepository;
|
||||
|
|
@ -11,6 +10,8 @@ import io.quarkus.panache.common.Sort;
|
|||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.wildfly.common.Assert;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
|
|
@ -21,10 +22,11 @@ public class PostService {
|
|||
@Inject PostRepository postRepository;
|
||||
@Inject AssetRepository assetRepository;
|
||||
@Inject AssetService assetService;
|
||||
@Inject Logger log;
|
||||
|
||||
@Transactional
|
||||
public List<PostNestedEntity> getAllPosts() {
|
||||
return postRepository.findAll().stream().map(post -> new PostNestedEntity()
|
||||
return postRepository.findAll(Sort.descending("id")).stream().map(post -> new PostNestedEntity()
|
||||
.withId(post.id)
|
||||
.withDescription(post.description)
|
||||
.withDate(post.date)
|
||||
|
|
@ -38,10 +40,6 @@ public class PostService {
|
|||
).toList())).toList();
|
||||
}
|
||||
|
||||
public List<Post> getAllPostsOrdered() {
|
||||
return postRepository.findAll(Sort.descending("id")).stream().toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void createPost(PostEntity post) {
|
||||
Assert.assertNotNull(post);
|
||||
|
|
@ -53,19 +51,73 @@ public class PostService {
|
|||
Assert.assertNotNull(post.assets);
|
||||
Assert.assertFalse(post.assets.isEmpty());
|
||||
|
||||
List<Asset> postAssets = post.assets.stream().map(assetRepository::findById).toList();
|
||||
Post model = new Post().withDescription(post.description)
|
||||
.withDate(ZonedDateTime.now())
|
||||
.withLatitude(post.latitude)
|
||||
.withLongitude(post.longitude)
|
||||
.withCity(post.city)
|
||||
.withCountry(post.country)
|
||||
.withAssets(postAssets);
|
||||
final Post model = new Post().withDescription(post.description)
|
||||
.withDate(ZonedDateTime.now())
|
||||
.withLatitude(post.latitude)
|
||||
.withLongitude(post.longitude)
|
||||
.withCity(post.city)
|
||||
.withCountry(post.country);
|
||||
postRepository.persist(model);
|
||||
postAssets.forEach(asset -> asset.setPost(model));
|
||||
postAssets.forEach(asset -> assetRepository.persist(asset));
|
||||
|
||||
final int updatedAssetCount = assetRepository.update("post.id = ?1 where post.id is null and id in ?2",
|
||||
model.id,
|
||||
post.assets);
|
||||
if (updatedAssetCount != post.assets.size()) {
|
||||
log.debug(String.format("post create error: updated %d assets, expected %d",
|
||||
updatedAssetCount,
|
||||
post.assets.size()));
|
||||
throw new BadRequestException("one ore more asset does not exist or is already in a post");
|
||||
}
|
||||
|
||||
post.id = model.id;
|
||||
post.date = model.date;
|
||||
}
|
||||
|
||||
public PostNestedEntity getPost(Long id) {
|
||||
final Post post = postRepository.findById(id);
|
||||
|
||||
return new PostNestedEntity()
|
||||
.withId(post.id)
|
||||
.withDescription(post.description)
|
||||
.withDate(post.date)
|
||||
.withLatitude(post.latitude)
|
||||
.withLongitude(post.longitude)
|
||||
.withCity(post.city)
|
||||
.withCountry(post.country)
|
||||
.withAssets(post.assets.stream().map(asset -> new AssetEntity()
|
||||
.withId(asset.id)
|
||||
.withPresignedUrl(assetService.getPresignedUrlForAsset(asset))
|
||||
).toList());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PostEntity updatePost(PostEntity post) {
|
||||
final Post model = postRepository.findById(post.id);
|
||||
|
||||
assetRepository.update("post.id = null where post.id = ?1 and id not in ?2", post.id, post.assets);
|
||||
final var updatedAssetCount = assetRepository.update(
|
||||
"post.id = ?1 where (post.id is null or post.id = ?1) and id in ?2",
|
||||
post.id,
|
||||
post.assets);
|
||||
if (updatedAssetCount != post.assets.size())
|
||||
throw new BadRequestException("one ore more asset does not exist or is already in a post");
|
||||
|
||||
model.setDescription(post.description);
|
||||
model.setLatitude(post.latitude);
|
||||
model.setLongitude(post.longitude);
|
||||
model.setCity(post.city);
|
||||
model.setCountry(post.country);
|
||||
|
||||
postRepository.persist(model);
|
||||
|
||||
post.date = model.date;
|
||||
return post;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deletePost(Long id) {
|
||||
final Post post = postRepository.findById(id);
|
||||
post.assets.forEach(assetService::deleteAsset);
|
||||
postRepository.delete(post);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import org.wildfly.common.Assert;
|
|||
|
||||
import java.util.Map;
|
||||
|
||||
@Path("/admin/assets")
|
||||
@Path("/assets")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class AdminAssetApi {
|
||||
|
|
@ -25,7 +25,8 @@ public class AdminAssetApi {
|
|||
@Inject AuthService authService;
|
||||
|
||||
@POST
|
||||
public CreateAssets.Response createAsset(@RestHeader("X-admin-token") final String adminToken, CreateAssets.Request request) {
|
||||
public CreateAssets.Response createAsset(@RestHeader("X-admin-token") final String adminToken,
|
||||
CreateAssets.Request request) {
|
||||
if (!authService.isAdminTokenValid(adminToken))
|
||||
throw new NotAuthorizedException("provided admin token is invalid");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
package fr.kektus.summer2024.presentation.rest;
|
||||
|
||||
import fr.kektus.summer2024.domain.entity.PostEntity;
|
||||
import fr.kektus.summer2024.domain.service.AuthService;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.NotAuthorizedException;
|
||||
import jakarta.ws.rs.Path;
|
||||
|
|
@ -14,7 +12,7 @@ import lombok.NoArgsConstructor;
|
|||
import lombok.With;
|
||||
import org.jboss.resteasy.reactive.RestHeader;
|
||||
|
||||
@Path("/admin/auth")
|
||||
@Path("/auth")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class AdminAuthApi {
|
||||
@Inject AuthService authService;
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
package fr.kektus.summer2024.presentation.rest;
|
||||
|
||||
import fr.kektus.summer2024.Validators;
|
||||
import fr.kektus.summer2024.domain.entity.LocationEntity;
|
||||
import fr.kektus.summer2024.domain.service.AuthService;
|
||||
import fr.kektus.summer2024.domain.service.LocationService;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.NotAuthorizedException;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.With;
|
||||
import org.jboss.resteasy.reactive.RestHeader;
|
||||
|
||||
@Path("/admin/location")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class AdminLocationApi {
|
||||
@Inject AuthService authService;
|
||||
@Inject LocationService locationService;
|
||||
|
||||
@POST
|
||||
public LocationEntity createPost(@RestHeader("X-admin-token") final String adminToken,
|
||||
CreateLocation.Request request) {
|
||||
if (!authService.isAdminTokenValid(adminToken))
|
||||
throw new NotAuthorizedException("provided admin token is invalid");
|
||||
|
||||
Validators.assertNotNull(request, new BadRequestException());
|
||||
Validators.assertNotNull(request.latitude, new BadRequestException());
|
||||
Validators.assertNotNull(request.longitude, new BadRequestException());
|
||||
|
||||
return locationService.createLocation(request.latitude, request.longitude);
|
||||
}
|
||||
|
||||
public static class CreateLocation {
|
||||
@NoArgsConstructor @AllArgsConstructor @With
|
||||
public static class Request {
|
||||
public Float latitude;
|
||||
public Float longitude;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
package fr.kektus.summer2024.presentation.rest;
|
||||
|
||||
import fr.kektus.summer2024.domain.entity.PostEntity;
|
||||
import fr.kektus.summer2024.domain.entity.PostNestedEntity;
|
||||
import fr.kektus.summer2024.domain.service.AuthService;
|
||||
import fr.kektus.summer2024.domain.service.PostService;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.NotAuthorizedException;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.With;
|
||||
import org.jboss.resteasy.reactive.RestHeader;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Path("/admin/posts")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class AdminPostApi {
|
||||
@Inject PostService postService;
|
||||
@Inject AuthService authService;
|
||||
|
||||
@POST
|
||||
public PostEntity createPost(@RestHeader("X-admin-token") final String adminToken, CreatePost.Request request) {
|
||||
if (!authService.isAdminTokenValid(adminToken))
|
||||
throw new NotAuthorizedException("provided admin token is invalid");
|
||||
|
||||
PostEntity post = new PostEntity().withDescription(request.description)
|
||||
.withLatitude(request.latitude)
|
||||
.withLongitude(request.longitude)
|
||||
.withCity(request.city)
|
||||
.withCountry(request.country)
|
||||
.withAssets(request.assets);
|
||||
|
||||
postService.createPost(post);
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
@GET
|
||||
public List<PostNestedEntity> getPosts(@RestHeader("X-admin-token") final String adminToken) {
|
||||
if (!authService.isAdminTokenValid(adminToken))
|
||||
throw new NotAuthorizedException("provided admin token is invalid");
|
||||
|
||||
return postService.getAllPosts();
|
||||
}
|
||||
|
||||
public static class CreatePost {
|
||||
@NoArgsConstructor @AllArgsConstructor @With
|
||||
public static class Request {
|
||||
public String description;
|
||||
public Float latitude;
|
||||
public Float longitude;
|
||||
public String city;
|
||||
public String country;
|
||||
public List<Long> assets;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,51 @@
|
|||
package fr.kektus.summer2024.presentation.rest;
|
||||
|
||||
import fr.kektus.summer2024.Validators;
|
||||
import fr.kektus.summer2024.domain.entity.LocationEntity;
|
||||
import fr.kektus.summer2024.domain.service.AuthService;
|
||||
import fr.kektus.summer2024.domain.service.LocationService;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.NotAuthorizedException;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.With;
|
||||
import org.jboss.resteasy.reactive.RestHeader;
|
||||
|
||||
@Path("/location")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class LocationApi {
|
||||
@Inject AuthService authService;
|
||||
@Inject LocationService locationService;
|
||||
|
||||
@GET @Path("/last")
|
||||
public LocationEntity getLastLocation() {
|
||||
return locationService.getLatestLocation();
|
||||
}
|
||||
|
||||
@POST
|
||||
public LocationEntity createPost(@RestHeader("X-admin-token") final String adminToken,
|
||||
CreateLocation.Request request) {
|
||||
if (!authService.isAdminTokenValid(adminToken))
|
||||
throw new NotAuthorizedException("provided admin token is invalid");
|
||||
|
||||
Validators.assertNotNull(request, new BadRequestException());
|
||||
Validators.assertNotNull(request.latitude, new BadRequestException());
|
||||
Validators.assertNotNull(request.longitude, new BadRequestException());
|
||||
|
||||
return locationService.createLocation(request.latitude, request.longitude);
|
||||
}
|
||||
|
||||
public static class CreateLocation {
|
||||
@NoArgsConstructor @AllArgsConstructor @With
|
||||
public static class Request {
|
||||
public Float latitude;
|
||||
public Float longitude;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
package fr.kektus.summer2024.presentation.rest;
|
||||
|
||||
import fr.kektus.summer2024.converters.PublicPostConverter;
|
||||
import fr.kektus.summer2024.domain.entity.PostEntity;
|
||||
import fr.kektus.summer2024.domain.entity.PostNestedEntity;
|
||||
import fr.kektus.summer2024.domain.service.AuthService;
|
||||
import fr.kektus.summer2024.domain.service.PostService;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.NotAuthorizedException;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.With;
|
||||
import org.jboss.resteasy.reactive.RestHeader;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Path("/posts")
|
||||
|
|
@ -22,29 +27,89 @@ import java.util.List;
|
|||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class PostApi {
|
||||
@Inject PostService postService;
|
||||
@Inject PublicPostConverter postConverter;
|
||||
@Inject AuthService authService;
|
||||
|
||||
@NoArgsConstructor @AllArgsConstructor @With
|
||||
@Getter @Setter
|
||||
public static class PostDto {
|
||||
@NoArgsConstructor @AllArgsConstructor @With
|
||||
@Getter @Setter
|
||||
public static class Location {
|
||||
public Float lat;
|
||||
public Float lon;
|
||||
public String city;
|
||||
public String country;
|
||||
}
|
||||
@GET
|
||||
public List<PostNestedEntity> getAllPosts() {
|
||||
return postService.getAllPosts();
|
||||
}
|
||||
|
||||
public Long id;
|
||||
public ZonedDateTime date;
|
||||
public String description;
|
||||
public Location location;
|
||||
public List<String> assets;
|
||||
@POST
|
||||
public PostEntity createPost(@RestHeader("X-admin-token") final String adminToken, CreatePost.Request request) {
|
||||
if (!authService.isAdminTokenValid(adminToken))
|
||||
throw new NotAuthorizedException("provided admin token is invalid");
|
||||
|
||||
PostEntity post = new PostEntity().withDescription(request.description)
|
||||
.withLatitude(request.latitude)
|
||||
.withLongitude(request.longitude)
|
||||
.withCity(request.city)
|
||||
.withCountry(request.country)
|
||||
.withAssets(request.assets);
|
||||
|
||||
postService.createPost(post);
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
@GET
|
||||
public List<PostDto> list() {
|
||||
return postService.getAllPostsOrdered().stream().map(postConverter::toPostDto).toList();
|
||||
@Path("/{id}")
|
||||
public PostNestedEntity getPost(@RestHeader("X-admin-token") final String adminToken,
|
||||
@PathParam("id") final Long id) {
|
||||
if (!authService.isAdminTokenValid(adminToken))
|
||||
throw new NotAuthorizedException("provided admin token is invalid");
|
||||
|
||||
return postService.getPost(id);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
public void deletePost(@RestHeader("X-admin-token") final String adminToken, @PathParam("id") final Long id) {
|
||||
if (!authService.isAdminTokenValid(adminToken))
|
||||
throw new NotAuthorizedException("provided admin token is invalid");
|
||||
|
||||
postService.deletePost(id);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public PostEntity updatePost(@RestHeader("X-admin-token") final String adminToken,
|
||||
@PathParam("id") final Long id,
|
||||
final UpdatePost.Request request) {
|
||||
if (!authService.isAdminTokenValid(adminToken))
|
||||
throw new NotAuthorizedException("provided admin token is invalid");
|
||||
|
||||
return postService.updatePost(new PostEntity()
|
||||
.withId(id)
|
||||
.withDescription(request.description)
|
||||
.withLatitude(request.latitude)
|
||||
.withLongitude(request.longitude)
|
||||
.withCity(request.city)
|
||||
.withCountry(request.country)
|
||||
.withAssets(request.assets)
|
||||
);
|
||||
}
|
||||
|
||||
public static class CreatePost {
|
||||
@NoArgsConstructor @AllArgsConstructor @With
|
||||
public static class Request {
|
||||
public String description;
|
||||
public Float latitude;
|
||||
public Float longitude;
|
||||
public String city;
|
||||
public String country;
|
||||
public List<Long> assets;
|
||||
}
|
||||
}
|
||||
|
||||
public static class UpdatePost {
|
||||
@NoArgsConstructor @AllArgsConstructor @With
|
||||
public static class Request {
|
||||
public String description;
|
||||
public Float latitude;
|
||||
public Float longitude;
|
||||
public String city;
|
||||
public String country;
|
||||
public List<Long> assets;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue