parent
36dd8f46da
commit
e9c468fdc8
@ -0,0 +1,78 @@
|
||||
package fr.uca.iut.codecs.move;
|
||||
|
||||
import com.mongodb.MongoClientSettings;
|
||||
import fr.uca.iut.codecs.GenericCodec;
|
||||
import fr.uca.iut.codecs.type.TypeCodecUtil;
|
||||
import fr.uca.iut.entities.Move;
|
||||
import fr.uca.iut.entities.Type;
|
||||
import fr.uca.iut.utils.enums.MoveCategoryName;
|
||||
import org.bson.BsonReader;
|
||||
import org.bson.BsonWriter;
|
||||
import org.bson.Document;
|
||||
import org.bson.codecs.Codec;
|
||||
import org.bson.codecs.DecoderContext;
|
||||
import org.bson.codecs.EncoderContext;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
public class MoveCodec extends GenericCodec<Move> {
|
||||
private final Codec<Document> documentCodec;
|
||||
|
||||
public MoveCodec() {
|
||||
this.documentCodec = MongoClientSettings.getDefaultCodecRegistry()
|
||||
.get(Document.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void encode(BsonWriter writer, Move move, EncoderContext encoderContext) {
|
||||
Document doc = new Document();
|
||||
|
||||
doc.put("_id", new ObjectId(move.getId()));
|
||||
|
||||
doc.put("name", move.getName());
|
||||
|
||||
doc.put("category", move.getCategory());
|
||||
|
||||
doc.put("power", move.getPower());
|
||||
|
||||
doc.put("accuracy", move.getAccuracy());
|
||||
|
||||
Type moveType = move.getType();
|
||||
Document typeDoc = new Document();
|
||||
typeDoc.put("name",
|
||||
moveType.getName()
|
||||
.toString());
|
||||
typeDoc.put("weakAgainst", moveType.getWeakAgainst());
|
||||
typeDoc.put("effectiveAgainst", moveType.getEffectiveAgainst());
|
||||
doc.put("type", typeDoc);
|
||||
|
||||
documentCodec.encode(writer, doc, encoderContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<Move> getEncoderClass() {
|
||||
return Move.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Move decode(BsonReader reader, DecoderContext decoderContext) {
|
||||
Document document = documentCodec.decode(reader, decoderContext);
|
||||
Move move = new Move();
|
||||
|
||||
move.setId(document.getObjectId("_id")
|
||||
.toString());
|
||||
|
||||
move.setName(document.getString("name"));
|
||||
|
||||
move.setCategory(MoveCategoryName.valueOf(document.getString("category")));
|
||||
|
||||
move.setPower(document.getInteger("power"));
|
||||
|
||||
move.setAccuracy(document.getInteger("accuracy"));
|
||||
|
||||
Document typeDoc = (Document) document.get("type");
|
||||
|
||||
move.setType(TypeCodecUtil.extractType(typeDoc));
|
||||
|
||||
return move;
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package fr.uca.iut.codecs.move;
|
||||
|
||||
import com.mongodb.lang.Nullable;
|
||||
import fr.uca.iut.entities.Move;
|
||||
import org.bson.codecs.Codec;
|
||||
import org.bson.codecs.configuration.CodecProvider;
|
||||
import org.bson.codecs.configuration.CodecRegistry;
|
||||
|
||||
public class MoveCodecProvider implements CodecProvider {
|
||||
@Nullable
|
||||
@Override
|
||||
public <T> Codec<T> get(Class<T> clazz, CodecRegistry registry) {
|
||||
if (clazz.equals(Move.class)) {
|
||||
return (Codec<T>) new MoveCodec();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
package fr.uca.iut.codecs.type;
|
||||
|
||||
import com.mongodb.MongoClientSettings;
|
||||
import fr.uca.iut.entities.Type;
|
||||
import fr.uca.iut.utils.TypeName;
|
||||
import org.bson.*;
|
||||
import org.bson.codecs.Codec;
|
||||
import org.bson.codecs.DecoderContext;
|
||||
import org.bson.codecs.EncoderContext;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class TypeCodec implements Codec<Type> {
|
||||
private final Codec<Document> documentCodec;
|
||||
|
||||
public TypeCodec() {
|
||||
this.documentCodec = MongoClientSettings.getDefaultCodecRegistry()
|
||||
.get(Document.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void encode(BsonWriter writer, Type type, EncoderContext encoderContext) {
|
||||
Document doc = new Document();
|
||||
Optional.ofNullable(type.getName())
|
||||
.map(Enum::name)
|
||||
.ifPresent(name -> doc.put("name", name));
|
||||
|
||||
Optional.ofNullable(type.getWeakAgainst())
|
||||
.map(weakAgainst -> weakAgainst.stream().map(Enum::name).collect(Collectors.toList()))
|
||||
.ifPresent(weakAgainst -> doc.put("weakAgainst", weakAgainst));
|
||||
|
||||
Optional.ofNullable(type.getEffectiveAgainst())
|
||||
.map(effectiveAgainst -> effectiveAgainst.stream().map(Enum::name).collect(Collectors.toList()))
|
||||
.ifPresent(effectiveAgainst -> doc.put("effectiveAgainst", effectiveAgainst));
|
||||
|
||||
documentCodec.encode(writer, doc, encoderContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<Type> getEncoderClass() {
|
||||
return Type.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Type decode(BsonReader reader, DecoderContext decoderContext) {
|
||||
Document document = documentCodec.decode(reader, decoderContext);
|
||||
Type type = new Type();
|
||||
|
||||
Optional.ofNullable(document.getString("name"))
|
||||
.map(TypeName::valueOf)
|
||||
.ifPresent(type::setName);
|
||||
|
||||
Optional.ofNullable(document.get("weakAgainst"))
|
||||
.filter(obj -> obj instanceof List<?>)
|
||||
.map(obj -> ((List<String>) obj).stream().map(TypeName::valueOf).collect(Collectors.toList()))
|
||||
.ifPresent(type::setWeakAgainst);
|
||||
|
||||
Optional.ofNullable(document.get("effectiveAgainst"))
|
||||
.filter(obj -> obj instanceof List<?>)
|
||||
.map(obj -> ((List<String>) obj).stream().map(TypeName::valueOf).collect(Collectors.toList()))
|
||||
.ifPresent(type::setEffectiveAgainst);
|
||||
|
||||
return type;
|
||||
}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
package fr.uca.iut.codecs.type;
|
||||
|
||||
import fr.uca.iut.entities.Type;
|
||||
import org.bson.codecs.Codec;
|
||||
import org.bson.codecs.configuration.CodecProvider;
|
||||
import org.bson.codecs.configuration.CodecRegistry;
|
||||
|
||||
public class TypeCodecProvider implements CodecProvider {
|
||||
@Override
|
||||
public <T> Codec<T> get(Class<T> clazz, CodecRegistry registry) {
|
||||
if (clazz.equals(Type.class)) {
|
||||
return (Codec<T>) new TypeCodec();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
package fr.uca.iut.codecs.type;
|
||||
|
||||
import fr.uca.iut.entities.Type;
|
||||
import fr.uca.iut.utils.enums.TypeName;
|
||||
import org.bson.Document;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class TypeCodecUtil {
|
||||
public static Type extractType(Document typeDoc) {
|
||||
Type type = new Type();
|
||||
type.setName(TypeName.valueOf(typeDoc.getString("name")));
|
||||
List<TypeName> weakAgainst = typeDoc.getList("weakAgainst", String.class)
|
||||
.stream()
|
||||
.map(TypeName::valueOf)
|
||||
.collect(Collectors.toList());
|
||||
type.setWeakAgainst(weakAgainst);
|
||||
List<TypeName> effectiveAgainst = typeDoc.getList("effectiveAgainst",
|
||||
String.class)
|
||||
.stream()
|
||||
.map(TypeName::valueOf)
|
||||
.collect(Collectors.toList());
|
||||
type.setEffectiveAgainst(effectiveAgainst);
|
||||
return type;
|
||||
}
|
||||
}
|
@ -0,0 +1,108 @@
|
||||
package fr.uca.iut.controllers;
|
||||
|
||||
import fr.uca.iut.entities.GenericEntity;
|
||||
import fr.uca.iut.services.GenericService;
|
||||
import fr.uca.iut.utils.exceptions.NonValidEntityException;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
public abstract class GenericController<T extends GenericEntity> {
|
||||
|
||||
protected GenericService<T> service;
|
||||
|
||||
public void setService(GenericService<T> service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
public Response getOneById(@PathParam("id") String id) {
|
||||
try {
|
||||
T entity = service.getOneById(id);
|
||||
if (entity != null) {
|
||||
return Response.ok(entity)
|
||||
.build();
|
||||
}
|
||||
else {
|
||||
return Response.status(Response.Status.NOT_FOUND)
|
||||
.entity("Entity not found for id: " + id)
|
||||
.build();
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity("Invalid id format: " + id)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
public Response getAll() {
|
||||
return Response.ok(service.getAll())
|
||||
.build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public Response createOne(T entity) {
|
||||
|
||||
try {
|
||||
service.validateOne(entity);
|
||||
T newEntity = service.addOne(entity);
|
||||
|
||||
return Response.status(Response.Status.CREATED)
|
||||
.entity(newEntity)
|
||||
.build();
|
||||
|
||||
} catch (NonValidEntityException e) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(e.getMessage())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public Response updateOne(@PathParam("id") String id, T entity) {
|
||||
try {
|
||||
service.validateOne(entity);
|
||||
entity.setId(id);
|
||||
T updatedEntity = service.updateOne(entity);
|
||||
|
||||
if (updatedEntity != null) {
|
||||
return Response.status(Response.Status.OK)
|
||||
.entity(updatedEntity)
|
||||
.build();
|
||||
}
|
||||
else {
|
||||
return Response.status(Response.Status.NOT_FOUND)
|
||||
.entity("Entity not found for id: " + id)
|
||||
.build();
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity("Invalid id format: " + id)
|
||||
.build();
|
||||
} catch (NonValidEntityException e) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(e.getMessage())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
public Response deleteOneById(@PathParam("id") String id) {
|
||||
try {
|
||||
service.deleteOneById(id);
|
||||
return Response.ok()
|
||||
.build();
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity("Invalid id format: " + id)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
package fr.uca.iut.controllers;
|
||||
|
||||
import fr.uca.iut.entities.Move;
|
||||
import fr.uca.iut.services.MoveService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
|
||||
@Path("/move")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class MoveController extends GenericController<Move> {
|
||||
|
||||
@Inject
|
||||
MoveService moveService;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
setService(moveService);
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package fr.uca.iut.entities;
|
||||
|
||||
public class PokemongMove extends GenericEntity {
|
||||
|
||||
private String name;
|
||||
|
||||
public PokemongMove() {}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
@NonNullApi
|
||||
package fr.uca.iut;
|
||||
|
||||
import com.mongodb.lang.NonNullApi;
|
@ -0,0 +1,64 @@
|
||||
package fr.uca.iut.repositories;
|
||||
|
||||
import com.mongodb.client.MongoClient;
|
||||
import com.mongodb.client.MongoCollection;
|
||||
import com.mongodb.client.model.ReplaceOptions;
|
||||
import com.mongodb.lang.Nullable;
|
||||
import fr.uca.iut.entities.GenericEntity;
|
||||
import org.bson.Document;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static com.mongodb.client.model.Filters.eq;
|
||||
|
||||
public abstract class GenericRepository<T extends GenericEntity> {
|
||||
protected MongoClient mongoClient;
|
||||
@ConfigProperty(name = "quarkus.mongodb.database")
|
||||
String DB_NAME;
|
||||
|
||||
public void setMongoClient(MongoClient mongoClient) {
|
||||
this.mongoClient = mongoClient;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public T findById(String id) {
|
||||
return getCollection().find(eq("_id", new ObjectId(id)))
|
||||
.first();
|
||||
}
|
||||
|
||||
protected abstract MongoCollection<T> getCollection();
|
||||
|
||||
public void persist(@NotNull T entity) {
|
||||
getCollection().insertOne(entity);
|
||||
}
|
||||
|
||||
public List<T> listAll() {
|
||||
return getCollection().find()
|
||||
.into(new ArrayList<>());
|
||||
}
|
||||
|
||||
public void persistOrUpdate(@NotNull T entity) {
|
||||
getCollection().replaceOne(
|
||||
eq("_id", new ObjectId(entity.getId())),
|
||||
entity,
|
||||
new ReplaceOptions().upsert(true)
|
||||
);
|
||||
}
|
||||
|
||||
public void delete(@NotNull T entity) {
|
||||
getCollection().deleteOne(eq("_id", new ObjectId(entity.getId())));
|
||||
}
|
||||
|
||||
public boolean existsById(String id) {
|
||||
// FIXME Can't post trainers anymore: "Caused by: java.lang.IllegalArgumentException: hexString can not be null
|
||||
// at org.bson.assertions.Assertions.notNull(Assertions.java:37)
|
||||
// at org.bson.types.ObjectId.parseHexString(ObjectId.java:418)
|
||||
// at org.bson.types.ObjectId.<init>(ObjectId.java:205)"
|
||||
Document query = new Document("_id", new ObjectId(id));
|
||||
return getCollection().countDocuments(query) > 0;
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
package fr.uca.iut.repositories;
|
||||
|
||||
import com.mongodb.client.MongoClient;
|
||||
import com.mongodb.client.MongoCollection;
|
||||
import com.mongodb.client.MongoDatabase;
|
||||
import fr.uca.iut.entities.Move;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
@ApplicationScoped
|
||||
public class MoveRepository extends GenericRepository<Move> {
|
||||
|
||||
// FIXME?
|
||||
/**
|
||||
* Warns that "Unsatisfied dependency: no bean matches the injection point"
|
||||
* but the app works
|
||||
*/
|
||||
@Inject
|
||||
MongoClient mongoClient;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
setMongoClient(mongoClient);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected MongoCollection<Move> getCollection() {
|
||||
MongoDatabase db = mongoClient.getDatabase(DB_NAME);
|
||||
return db.getCollection(Move.COLLECTION_NAME, Move.class);
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
package fr.uca.iut.services;
|
||||
|
||||
import com.mongodb.lang.Nullable;
|
||||
import fr.uca.iut.entities.GenericEntity;
|
||||
import fr.uca.iut.repositories.GenericRepository;
|
||||
import fr.uca.iut.utils.exceptions.NonValidEntityException;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public abstract class GenericService<T extends GenericEntity> {
|
||||
|
||||
protected GenericRepository<T> repository;
|
||||
|
||||
public void setRepository(GenericRepository<T> repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public T addOne(@NotNull T entity) {
|
||||
repository.persist(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public T getOneById(String id) {
|
||||
return repository.findById(id);
|
||||
}
|
||||
|
||||
public List<T> getAll() {
|
||||
return repository.listAll();
|
||||
}
|
||||
|
||||
public void deleteOneById(String id) {
|
||||
T entity = repository.findById(id);
|
||||
if (entity != null) {
|
||||
repository.delete(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public abstract T updateOne(@NotNull T entity);
|
||||
|
||||
/**
|
||||
* Override me and start with `super.validateOne(entity);`
|
||||
*/
|
||||
public void validateOne(T entity) {
|
||||
if (entity == null) {
|
||||
throw new NonValidEntityException("entity was null");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
package fr.uca.iut.services;
|
||||
|
||||
import com.mongodb.lang.Nullable;
|
||||
import fr.uca.iut.entities.Move;
|
||||
import fr.uca.iut.entities.Pokemong;
|
||||
import fr.uca.iut.repositories.MoveRepository;
|
||||
import fr.uca.iut.utils.StringUtils;
|
||||
import fr.uca.iut.utils.exceptions.NonValidEntityException;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ApplicationScoped
|
||||
public class MoveService extends GenericService<Move> {
|
||||
|
||||
@Inject
|
||||
MoveRepository moveRepository;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
setRepository(moveRepository);
|
||||
}
|
||||
|
||||
@Inject
|
||||
PokemongService pokemongService;
|
||||
|
||||
@Override
|
||||
public void deleteOneById(String id) {
|
||||
super.deleteOneById(id);
|
||||
// FIXME the deleted move does not get its PokemongMove deleted from any Pokemong's moveset in DB after a move is deleted
|
||||
List<Pokemong> pokemongs = pokemongService.findByMove(id);
|
||||
for (Pokemong pokemong : pokemongs) {
|
||||
pokemong.removeMove(id);
|
||||
pokemongService.updateOne(pokemong);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public Move updateOne(@NotNull Move move) {
|
||||
Move existingMove = moveRepository.findById(move.getId());
|
||||
if (existingMove != null) {
|
||||
existingMove.setName(move.getName());
|
||||
// FIXME the updated name does not appear in DB after a move is updated
|
||||
List<Pokemong> pokemongs = pokemongService.findByMove(move.getId());
|
||||
for (Pokemong pokemong : pokemongs) {
|
||||
pokemong.updateMove(move.getId(), move.getName());
|
||||
pokemongService.updateOne(pokemong);
|
||||
}
|
||||
|
||||
existingMove.setPower(move.getPower());
|
||||
existingMove.setCategory(move.getCategory());
|
||||
existingMove.setAccuracy(move.getAccuracy());
|
||||
existingMove.setType(move.getType());
|
||||
moveRepository.persistOrUpdate(existingMove);
|
||||
}
|
||||
return existingMove;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateOne(Move move) {
|
||||
|
||||
super.validateOne(move);
|
||||
|
||||
if (StringUtils.isBlankString(move.getName())) {
|
||||
throw new NonValidEntityException("move name was null, blank or empty");
|
||||
}
|
||||
|
||||
if (move.getPower() == null || move.getPower() < 0) {
|
||||
throw new NonValidEntityException("move power was null or negative");
|
||||
}
|
||||
|
||||
if (move.getCategory() == null) {
|
||||
throw new NonValidEntityException("move category was null or invalid");
|
||||
}
|
||||
|
||||
if (move.getAccuracy() == null || move.getAccuracy() < 0) {
|
||||
throw new NonValidEntityException("move accuracy was null or negative");
|
||||
}
|
||||
|
||||
if (move.getType() == null) {
|
||||
throw new NonValidEntityException("move type was null or invalid");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package fr.uca.iut.utils;
|
||||
|
||||
public class StringUtils {
|
||||
public static boolean isBlankString(String string) {
|
||||
return string == null || string.isBlank();
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package fr.uca.iut.utils.enums;
|
||||
|
||||
public enum MoveCategoryName {
|
||||
PHYSICAL,
|
||||
SPECIAL,
|
||||
STATUS
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package fr.uca.iut.utils;
|
||||
package fr.uca.iut.utils.enums;
|
||||
|
||||
public enum PokemongName {
|
||||
BULBASAUR,
|
@ -1,4 +1,4 @@
|
||||
package fr.uca.iut.utils;
|
||||
package fr.uca.iut.utils.enums;
|
||||
|
||||
public enum TypeName {
|
||||
NORMAL,
|
@ -0,0 +1,7 @@
|
||||
package fr.uca.iut.utils.exceptions;
|
||||
|
||||
public class NonValidEntityException extends RuntimeException {
|
||||
public NonValidEntityException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
Loading…
Reference in new issue