alfio.manager.system.DataMigrator.java Source code

Java tutorial

Introduction

Here is the source code for alfio.manager.system.DataMigrator.java

Source

/**
 * This file is part of alf.io.
 *
 * alf.io is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * alf.io is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with alf.io.  If not, see <http://www.gnu.org/licenses/>.
 */
package alfio.manager.system;

import alfio.model.*;
import alfio.model.system.ConfigurationKeys;
import alfio.model.system.EventMigration;
import alfio.model.transaction.PaymentProxy;
import alfio.repository.EventRepository;
import alfio.repository.TicketCategoryRepository;
import alfio.repository.system.ConfigurationRepository;
import alfio.repository.system.EventMigrationRepository;
import alfio.util.MonetaryUtil;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.Validate;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.namedparam.EmptySqlParameterSource;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;

import java.math.BigDecimal;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static alfio.model.PriceContainer.VatStatus.*;
import static alfio.repository.TicketRepository.RESET_TICKET;
import static alfio.util.OptionalWrapper.optionally;
import static java.util.stream.Collectors.*;

@Component
@Transactional(readOnly = true)
@Log4j2
public class DataMigrator {

    private static final Pattern VERSION_PATTERN = Pattern.compile("(\\d\\.)([0-9\\.]*)(-SNAPSHOT)?");
    private static final Map<String, String> PRICE_UPDATE_BY_KEY = new LinkedHashMap<>();
    private final EventMigrationRepository eventMigrationRepository;
    private final EventRepository eventRepository;
    private final TicketCategoryRepository ticketCategoryRepository;
    private final BigDecimal currentVersion;
    private final String currentVersionAsString;
    private final ZonedDateTime buildTimestamp;
    private final TransactionTemplate transactionTemplate;
    private final ConfigurationRepository configurationRepository;
    private final NamedParameterJdbcTemplate jdbc;

    static {
        PRICE_UPDATE_BY_KEY.put("event",
                "update event set src_price_cts = :srcPriceCts, vat_status = :vatStatus where id = :eventId");
        PRICE_UPDATE_BY_KEY.put("category",
                "update ticket_category set src_price_cts = :srcPriceCts where id = :categoryId");
        PRICE_UPDATE_BY_KEY.put("ticket",
                "update ticket set src_price_cts = :srcPriceCts, final_price_cts = :finalPriceCts, vat_cts = :vatCts, discount_cts = :discountCts where id = :ticketId");
        PRICE_UPDATE_BY_KEY.put("additional_service",
                "update additional_service set src_price_cts = :srcPriceCts where id = :additionalServiceId");
        PRICE_UPDATE_BY_KEY.put("additional_service_item",
                "update additional_service_item set src_price_cts = :srcPriceCts, final_price_cts = :finalPriceCts, vat_cts = :vatCts where id = :additionalServiceItemId");
    }

    @Autowired
    public DataMigrator(EventMigrationRepository eventMigrationRepository, EventRepository eventRepository,
            TicketCategoryRepository ticketCategoryRepository, @Value("${alfio.version}") String currentVersion,
            @Value("${alfio.build-ts}") String buildTimestamp, PlatformTransactionManager transactionManager,
            ConfigurationRepository configurationRepository, NamedParameterJdbcTemplate jdbc) {
        this.eventMigrationRepository = eventMigrationRepository;
        this.eventRepository = eventRepository;
        this.ticketCategoryRepository = ticketCategoryRepository;
        this.configurationRepository = configurationRepository;
        this.jdbc = jdbc;
        this.currentVersion = parseVersion(currentVersion);
        this.currentVersionAsString = currentVersion;
        this.buildTimestamp = ZonedDateTime.parse(buildTimestamp);
        this.transactionTemplate = new TransactionTemplate(transactionManager,
                new DefaultTransactionDefinition(TransactionDefinition.PROPAGATION_REQUIRES_NEW));
    }

    public void migrateEventsToCurrentVersion() {
        eventRepository.findAll().forEach(this::migrateEventToCurrentVersion);
        fillReservationsLanguage();
        fillDefaultOptions();
    }

    private void fillDefaultOptions() {
        transactionTemplate.execute(ts -> {
            int count = jdbc.queryForObject("select count(*) from configuration where c_key = :key",
                    new MapSqlParameterSource("key", ConfigurationKeys.GOOGLE_ANALYTICS_ANONYMOUS_MODE.getValue()),
                    Integer.class);
            if (count == 0) {
                configurationRepository.insert(ConfigurationKeys.GOOGLE_ANALYTICS_ANONYMOUS_MODE.getValue(), "true",
                        ConfigurationKeys.GOOGLE_ANALYTICS_ANONYMOUS_MODE.getDescription());
            }
            return null;
        });
    }

    private void migrateEventToCurrentVersion(Event event) {
        Optional<EventMigration> optional = optionally(
                () -> eventMigrationRepository.loadEventMigration(event.getId()));
        boolean alreadyDefined = optional.isPresent();
        if (!alreadyDefined || optional.filter(this::needsFixing).isPresent()) {
            transactionTemplate.execute(s -> {
                //optional.ifPresent(eventMigration -> eventMigrationRepository.lockEventMigrationForUpdate(eventMigration.getId()));
                if (ZonedDateTime.now(event.getZoneId()).isBefore(event.getEnd())) {
                    fixAvailableSeats(event);
                    fillDescriptions(event);
                    fixCategoriesSize(event);
                }

                //migrate prices to new structure. This should be done for all events, regardless of the expiration date.
                migratePrices(event.getId());
                fixStuckTickets(event.getId());

                if (alreadyDefined) {
                    EventMigration eventMigration = optional.get();
                    int result = eventMigrationRepository.updateMigrationData(eventMigration.getId(),
                            currentVersionAsString, buildTimestamp, EventMigration.Status.COMPLETE.name());
                    Validate.isTrue(result == 1, "error during update " + result);
                } else {
                    eventMigrationRepository.insertMigrationData(event.getId(), currentVersionAsString,
                            buildTimestamp, EventMigration.Status.COMPLETE.name());
                }

                return null;
            });
        }
    }

    void fixStuckTickets(int eventId) {
        List<Integer> ticketIds = jdbc.queryForList(
                "select a.id from ticket a, tickets_reservation b where a.event_id = :eventId and a.status = 'PENDING' and a.tickets_reservation_id = b.id and b.status = 'CANCELLED'",
                new MapSqlParameterSource("eventId", eventId), Integer.class);
        if (!ticketIds.isEmpty()) {
            int toBeFixed = ticketIds.size();
            log.warn("********* reverting {} stuck tickets ({}) for event id {}", toBeFixed, ticketIds, eventId);
            int[] results = jdbc.batchUpdate(
                    "update ticket set status = 'RELEASED'," + RESET_TICKET + " where id = :ticketId",
                    ticketIds.stream().map(id -> new MapSqlParameterSource("ticketId", id))
                            .toArray(MapSqlParameterSource[]::new));
            int result = Arrays.stream(results).sum();
            Validate.isTrue(result == toBeFixed,
                    "Error while fixing stuck tickets: expected " + toBeFixed + ", got " + result);
        }
    }

    void fixCategoriesSize(Event event) {
        ticketCategoryRepository.findByEventId(event.getId()).stream().filter(TicketCategory::isBounded)
                .forEach(tc -> {
                    Integer result = jdbc.queryForObject(
                            "select count(*) from ticket where event_id = :eventId and category_id = :categoryId and status <> 'INVALIDATED'",
                            new MapSqlParameterSource("eventId", tc.getEventId()).addValue("categoryId",
                                    tc.getId()),
                            Integer.class);
                    if (result != null && result != tc.getMaxTickets()) {
                        log.warn("********* updating category size for {} from {} to {} tickets", tc.getName(),
                                tc.getMaxTickets(), result);
                        ticketCategoryRepository.updateSeatsAvailability(tc.getId(), result);
                    }
                });

    }

    void fillReservationsLanguage() {
        transactionTemplate.execute(s -> {
            jdbc.queryForList("select id from tickets_reservation where user_language is null",
                    new EmptySqlParameterSource(), String.class).forEach(id -> {
                        MapSqlParameterSource param = new MapSqlParameterSource("reservationId", id);
                        String language = optionally(() -> jdbc.queryForObject(
                                "select user_language from ticket where tickets_reservation_id = :reservationId limit 1",
                                param, String.class)).orElse("en");
                        jdbc.update(
                                "update tickets_reservation set user_language = :userLanguage where id = :reservationId",
                                param.addValue("userLanguage", language));
                    });
            return null;
        });
    }

    private void fillDescriptions(Event event) {
        int result = eventRepository.fillDisplayNameIfRequired(event.getId());
        if (result > 0) {
            log.debug("Event {} didn't have displayName, filled with shortName", event.getShortName());
        }
    }

    /*
     * even if we don't actively use the "available_seats" event property anymore, it makes sense to keep it synchronized
     * in order to ensure backward compatibility
     */
    private void fixAvailableSeats(Event event) {
        int availableSeats = eventRepository.countExistingTickets(event.getId());
        eventRepository.updatePrices(event.getCurrency(), availableSeats, event.isVatIncluded(), event.getVat(),
                event.getAllowedPaymentProxies().stream().map(PaymentProxy::name).collect(joining(",")),
                event.getId(), event.getVatStatus(), event.getSrcPriceCts());
    }

    boolean needsFixing(EventMigration eventMigration) {
        return eventMigration.getBuildTimestamp().isBefore(buildTimestamp)
                || parseVersion(eventMigration.getCurrentVersion()).compareTo(currentVersion) < 0;
    }

    static BigDecimal parseVersion(String version) {
        Matcher matcher = VERSION_PATTERN.matcher(version);
        if (!matcher.find()) {
            return BigDecimal.ZERO;
        }
        return new BigDecimal(matcher.group(1) + matcher.group(2).replaceAll("\\.", ""));
    }

    private void migratePrices(final int eventId) {
        final Map<String, Integer> eventIdParam = Collections.singletonMap("eventId", eventId);
        final String srcPriceCtsParam = "srcPriceCts";
        Map<String, List<MapSqlParameterSource>> migrationData = jdbc.queryForList(
                "select * from event where type = :type and id = :eventId and regular_price_cts > 0",
                new MapSqlParameterSource("type", Event.EventType.INTERNAL.name()).addValue("eventId", eventId))
                .stream().flatMap(event -> {
                    //fill the event prices
                    boolean eventVatIncluded = (boolean) event.get("vat_included");
                    BigDecimal vatPercentage = Optional.ofNullable((BigDecimal) event.get("vat"))
                            .orElse(BigDecimal.ZERO);
                    int price = (int) event.get("regular_price_cts");
                    String currencyCode = (String) event.get("currency");
                    int eventSrcPrice = eventVatIncluded ? MonetaryUtil.addVAT(price, vatPercentage) : price;

                    List<Pair<String, MapSqlParameterSource>> modifications = new ArrayList<>();

                    if (((int) event.get("src_price_cts")) == 0) {
                        modifications
                                .add(Pair
                                        .of("event",
                                                new MapSqlParameterSource(srcPriceCtsParam, eventSrcPrice)
                                                        .addValue("vatStatus",
                                                                eventVatIncluded ? INCLUDED.name()
                                                                        : NOT_INCLUDED.name())
                                                        .addValue("eventId", eventId)));
                    }

                    //ticket categories
                    modifications.addAll(collectTicketCategoryMigrationData(srcPriceCtsParam, eventVatIncluded,
                            vatPercentage, eventIdParam));

                    //tickets
                    modifications.addAll(collectTicketMigrationData(srcPriceCtsParam, eventVatIncluded,
                            vatPercentage, currencyCode, eventId, eventIdParam));

                    //additional_service
                    modifications.addAll(collectASMigrationData(srcPriceCtsParam, eventVatIncluded, vatPercentage,
                            eventIdParam));

                    //additional_service_item
                    modifications.addAll(collectASItemMigrationData(srcPriceCtsParam, eventVatIncluded,
                            vatPercentage, currencyCode, eventIdParam));

                    log.debug("Price migration: got {} modifications for event {}", modifications.size(),
                            event.get("short_name"));
                    return modifications.stream();
                }).collect(groupingBy(Pair::getKey, mapping(Pair::getValue, toList())));

        if (migrationData.size() > 0) {
            log.debug("Price migration: got modifications for: {}", migrationData.keySet());
            PRICE_UPDATE_BY_KEY.entrySet().stream().filter(e -> migrationData.containsKey(e.getKey()))
                    .map(e -> Pair.of(e, migrationData.get(e.getKey()))).forEach(p -> {
                        Map.Entry<String, String> entry = p.getLeft();
                        log.debug("migrating {} prices...", entry.getKey());
                        performPriceMigration(entry.getValue(), p.getRight(), jdbc);
                    });
        }
    }

    private static void performPriceMigration(String updateStatement, List<MapSqlParameterSource> data,
            NamedParameterJdbcTemplate jdbc) {
        int size = data.size();
        jdbc.batchUpdate(updateStatement, data.toArray(new MapSqlParameterSource[size]));
        log.debug("{} records updated", size);
    }

    private List<Pair<String, MapSqlParameterSource>> collectASItemMigrationData(String srcPriceCtsParam,
            final boolean eventVatIncluded, final BigDecimal vatPercentage, final String currencyCode,
            Map<String, Integer> eventIdParam) {
        return jdbc.queryForList(
                "select ai.id as id, ai.paid_price_cts as paid_price_cts, a.vat_type as vat_type from additional_service_item ai, additional_service a where ai.paid_price_cts > 0 and ai.src_price_cts = 0 and ai.event_id_fk = :eventId",
                eventIdParam).stream().map(item -> {
                    int oldPrice = (int) item.get("paid_price_cts");
                    AdditionalService.VatType vatType = AdditionalService.VatType
                            .valueOf((String) item.get("vat_type"));
                    return Pair.of((Integer) item.get("id"), new PriceContainer() {
                        @Override
                        public int getSrcPriceCts() {
                            return eventVatIncluded ? MonetaryUtil.addVAT(oldPrice, vatPercentage) : oldPrice;
                        }

                        @Override
                        public String getCurrencyCode() {
                            return currencyCode;
                        }

                        @Override
                        public Optional<BigDecimal> getOptionalVatPercentage() {
                            return Optional.of(vatPercentage);
                        }

                        @Override
                        public VatStatus getVatStatus() {
                            if (vatType == AdditionalService.VatType.INHERITED) {
                                return eventVatIncluded ? INCLUDED : NOT_INCLUDED;
                            }
                            return NONE;//no need to check for other VatTypes. At the time of writing VAT override strategy has not yet been implemented.
                        }
                    });
                }).map(p -> {
                    PriceContainer priceContainer = p.getValue();
                    return Pair.of("additional_service_item",
                            new MapSqlParameterSource(srcPriceCtsParam, priceContainer.getSrcPriceCts())
                                    .addValue("finalPriceCts",
                                            MonetaryUtil.unitToCents(priceContainer.getFinalPrice()))
                                    .addValue("vatCts", MonetaryUtil.unitToCents(priceContainer.getVAT()))
                                    .addValue("additionalServiceItemId", p.getKey()));
                }).collect(toList());
    }

    private List<Pair<String, MapSqlParameterSource>> collectASMigrationData(String srcPriceCtsParam,
            boolean eventVatIncluded, BigDecimal vatPercentage, Map<String, Integer> eventIdParam) {
        return jdbc.queryForList(
                "select id, price_cts, vat_type from additional_service where event_id_fk = :eventId and fix_price = true and price_cts > 0 and src_price_cts = 0",
                eventIdParam).stream().map(as -> {
                    int priceCts = (int) as.get("price_cts");
                    AdditionalService.VatType vatType = AdditionalService.VatType
                            .valueOf((String) as.get("vat_type"));
                    int srcPrice = vatType == AdditionalService.VatType.INHERITED && eventVatIncluded
                            ? MonetaryUtil.addVAT(priceCts, vatPercentage)
                            : priceCts;
                    return Pair.of("additional_service", new MapSqlParameterSource(srcPriceCtsParam, srcPrice)
                            .addValue("additionalServiceId", as.get("id")));
                }).collect(toList());
    }

    private List<Pair<String, MapSqlParameterSource>> collectTicketMigrationData(String srcPriceCtsParam,
            final boolean eventVatIncluded, final BigDecimal vatPercentage, final String currencyCode,
            final int eventId, Map<String, Integer> eventIdParam) {
        return jdbc.queryForList(
                "select ticket.id as id, ticket.original_price_cts as original_price_cts, ticket.paid_price_cts as paid_price_cts, promo_code.discount_amount as discount_amount, promo_code.discount_type as discount_type from ticket join tickets_reservation on tickets_reservation.id = ticket.tickets_reservation_id left join promo_code on tickets_reservation.promo_code_id_fk = promo_code.id where ticket.event_id = :eventId and ticket.original_price_cts > 0 and ticket.src_price_cts = 0",
                eventIdParam).stream().map(ticket -> {
                    int oldTicketPrice = (int) ticket.get("original_price_cts");
                    return Pair.of((Integer) ticket.get("id"), new PriceContainer() {
                        @Override
                        public int getSrcPriceCts() {
                            return eventVatIncluded ? MonetaryUtil.addVAT(oldTicketPrice, vatPercentage)
                                    : oldTicketPrice;
                        }

                        @Override
                        public String getCurrencyCode() {
                            return currencyCode;
                        }

                        @Override
                        public Optional<BigDecimal> getOptionalVatPercentage() {
                            return Optional.of(vatPercentage);
                        }

                        @Override
                        public VatStatus getVatStatus() {
                            return eventVatIncluded ? INCLUDED : NOT_INCLUDED;
                        }

                        @Override
                        public Optional<PromoCodeDiscount> getDiscount() {
                            return Optional
                                    .ofNullable(
                                            ticket.get("discount_amount"))
                                    .map(amount -> new PromoCodeDiscount(0, "", eventId, null, null, null,
                                            (int) amount, PromoCodeDiscount.DiscountType
                                                    .valueOf((String) ticket.get("discount_type")),
                                            ""));
                        }
                    });
                }).map(p -> {
                    PriceContainer priceContainer = p.getValue();
                    return Pair.of("ticket",
                            new MapSqlParameterSource(srcPriceCtsParam, priceContainer.getSrcPriceCts())
                                    .addValue("finalPriceCts",
                                            MonetaryUtil.unitToCents(priceContainer.getFinalPrice()))
                                    .addValue("vatCts", MonetaryUtil.unitToCents(priceContainer.getVAT()))
                                    .addValue("discountCts",
                                            MonetaryUtil.unitToCents(priceContainer.getAppliedDiscount()))
                                    .addValue("ticketId", p.getKey()));
                }).collect(toList());
    }

    private List<Pair<String, MapSqlParameterSource>> collectTicketCategoryMigrationData(String srcPriceCtsParam,
            boolean eventVatIncluded, BigDecimal vatPercentage, Map<String, Integer> eventIdParam) {
        return jdbc.queryForList(
                "select id, price_cts from ticket_category where event_id = :eventId and price_cts > 0 and src_price_cts = 0",
                eventIdParam).stream().map(category -> {
                    int oldCategoryPrice = (int) category.get("price_cts");
                    int categorySrcPrice = eventVatIncluded ? MonetaryUtil.addVAT(oldCategoryPrice, vatPercentage)
                            : oldCategoryPrice;
                    return Pair.of("category", new MapSqlParameterSource(srcPriceCtsParam, categorySrcPrice)
                            .addValue("categoryId", category.get("id")));
                }).collect(toList());
    }
}