diff --git a/config.py b/config.py index f8027af9b..371adf8e2 100644 --- a/config.py +++ b/config.py @@ -95,7 +95,7 @@ def defPaths(customSavePath=None): global pyfaPath global savePath global saveDB - global gameDB + global gameDB global saveInRoot global logPath global cipher diff --git a/eos/db/gamedata/item.py b/eos/db/gamedata/item.py index fd7be477d..2683a41e9 100644 --- a/eos/db/gamedata/item.py +++ b/eos/db/gamedata/item.py @@ -41,8 +41,7 @@ items_table = Table("invtypes", gamedata_meta, Column("iconID", Integer), Column("graphicID", Integer), Column("groupID", Integer, ForeignKey("invgroups.groupID"), index=True), - Column("replaceSame", String), - Column("replaceBetter", String)) + Column("replacements", String)) from .metaGroup import metatypes_table # noqa from .traits import traits_table # noqa diff --git a/eos/db/saveddata/price.py b/eos/db/saveddata/price.py index 8abd07132..e0e0f530a 100644 --- a/eos/db/saveddata/price.py +++ b/eos/db/saveddata/price.py @@ -32,6 +32,4 @@ prices_table = Table("prices", saveddata_meta, Column("status", Integer, nullable=False)) -mapper(Price, prices_table, properties={ - "_Price__price": prices_table.c.price, -}) +mapper(Price, prices_table) diff --git a/eos/saveddata/booster.py b/eos/saveddata/booster.py index 4c6bf23db..3258fcfe3 100644 --- a/eos/saveddata/booster.py +++ b/eos/saveddata/booster.py @@ -30,6 +30,7 @@ pyfalog = Logger(__name__) class Booster(HandledItem, ItemAttrShortcut): + def __init__(self, item): self.__item = item @@ -147,3 +148,17 @@ class Booster(HandledItem, ItemAttrShortcut): copyEffect.active = sideEffect.active return copy + + def rebase(self, item): + active = self.active + sideEffectStates = {se.effectID: se.active for se in self.sideEffects} + Booster.__init__(self, item) + self.active = active + for sideEffect in self.sideEffects: + if sideEffect.effectID in sideEffectStates: + sideEffect.active = sideEffectStates[sideEffect.effectID] + + def __repr__(self): + return "Booster(ID={}, name={}) at {}".format( + self.item.ID, self.item.name, hex(id(self)) + ) diff --git a/eos/saveddata/cargo.py b/eos/saveddata/cargo.py index 11c017370..3fbfe29f0 100644 --- a/eos/saveddata/cargo.py +++ b/eos/saveddata/cargo.py @@ -89,3 +89,13 @@ class Cargo(HandledItem, ItemAttrShortcut): copy = Cargo(self.item) copy.amount = self.amount return copy + + def rebase(self, item): + amount = self.amount + Cargo.__init__(self, item) + self.amount = amount + + def __repr__(self): + return "Cargo(ID={}, name={}) at {}".format( + self.item.ID, self.item.name, hex(id(self)) + ) diff --git a/eos/saveddata/drone.py b/eos/saveddata/drone.py index 2fec27fc9..65ef5d327 100644 --- a/eos/saveddata/drone.py +++ b/eos/saveddata/drone.py @@ -296,6 +296,13 @@ class Drone(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut): copy.amountActive = self.amountActive return copy + def rebase(self, item): + amount = self.amount + amountActive = self.amountActive + Drone.__init__(self, item) + self.amount = amount + self.amountActive = amountActive + def fits(self, fit): fitDroneGroupLimits = set() for i in range(1, 3): diff --git a/eos/saveddata/fighter.py b/eos/saveddata/fighter.py index d0e9513f7..8024b0681 100644 --- a/eos/saveddata/fighter.py +++ b/eos/saveddata/fighter.py @@ -355,6 +355,17 @@ class Fighter(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut): copyAbility.active = ability.active return copy + def rebase(self, item): + amount = self.amount + active = self.active + abilityEffectStates = {a.effectID: a.active for a in self.abilities} + Fighter.__init__(self, item) + self.amount = amount + self.active = active + for ability in self.abilities: + if ability.effectID in abilityEffectStates: + ability.active = abilityEffectStates[ability.effectID] + def fits(self, fit): # If ships doesn't support this type of fighter, don't add it if fit.getNumSlots(self.slot) == 0: diff --git a/eos/saveddata/implant.py b/eos/saveddata/implant.py index d61b9c43b..5da13d28c 100644 --- a/eos/saveddata/implant.py +++ b/eos/saveddata/implant.py @@ -115,6 +115,11 @@ class Implant(HandledItem, ItemAttrShortcut): copy.active = self.active return copy + def rebase(self, item): + active = self.active + Implant.__init__(self, item) + self.active = active + def __repr__(self): return "Implant(ID={}, name={}) at {}".format( self.item.ID, self.item.name, hex(id(self)) diff --git a/eos/saveddata/module.py b/eos/saveddata/module.py index 8fd3117fc..24181748a 100644 --- a/eos/saveddata/module.py +++ b/eos/saveddata/module.py @@ -941,6 +941,16 @@ class Module(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut): return copy + def rebase(self, item): + state = self.state + charge = self.charge + Module.__init__(self, item, self.baseItem, self.mutaplasmid) + self.state = state + if self.isValidCharge(charge): + self.charge = charge + for x in self.mutators.values(): + Mutator(self, x.attribute, x.value) + def __repr__(self): if self.item: return "Module(ID={}, name={}) at {}".format( diff --git a/eos/saveddata/price.py b/eos/saveddata/price.py index a2a630c30..0a2a7ce9d 100644 --- a/eos/saveddata/price.py +++ b/eos/saveddata/price.py @@ -19,41 +19,56 @@ # =============================================================================== -import time from enum import IntEnum, unique +from time import time from logbook import Logger +VALIDITY = 24 * 60 * 60 # Price validity period, 24 hours +REREQUEST = 4 * 60 * 60 # Re-request delay for failed fetches, 4 hours +TIMEOUT = 15 * 60 # Network timeout delay for connection issues, 15 minutes + + pyfalog = Logger(__name__) @unique class PriceStatus(IntEnum): - notFetched = 0 - success = 1 - fail = 2 - notSupported = 3 + initialized = 0 + notSupported = 1 + fetchSuccess = 2 + fetchFail = 3 + fetchTimeout = 4 class Price(object): def __init__(self, typeID): self.typeID = typeID self.time = 0 - self.__price = 0 - self.status = PriceStatus.notFetched + self.price = 0 + self.status = PriceStatus.initialized - @property - def isValid(self): - return self.time >= time.time() - - @property - def price(self): - if self.status != PriceStatus.success: - return 0 + def isValid(self, validityOverride=None): + # Always attempt to update prices which were just initialized, and prices + # of unsupported items (maybe we start supporting them at some point) + if self.status in (PriceStatus.initialized, PriceStatus.notSupported): + return False + elif self.status == PriceStatus.fetchSuccess: + return time() <= self.time + (validityOverride if validityOverride is not None else VALIDITY) + elif self.status == PriceStatus.fetchFail: + return time() <= self.time + REREQUEST + elif self.status == PriceStatus.fetchTimeout: + return time() <= self.time + TIMEOUT else: - return self.__price or 0 + return False - @price.setter - def price(self, price): - self.__price = price + def update(self, status, price=0): + # Keep old price if we failed to fetch new one + if status in (PriceStatus.fetchFail, PriceStatus.fetchTimeout): + price = self.price + elif status != PriceStatus.fetchSuccess: + price = 0 + self.time = time() + self.price = price + self.status = status diff --git a/eve.db b/eve.db index 6556d4f06..2aba0fb7e 100644 Binary files a/eve.db and b/eve.db differ diff --git a/gui/builtinAdditionPanes/fighterView.py b/gui/builtinAdditionPanes/fighterView.py index 9a2550cd8..af98a46ed 100644 --- a/gui/builtinAdditionPanes/fighterView.py +++ b/gui/builtinAdditionPanes/fighterView.py @@ -122,8 +122,8 @@ class FighterDisplay(d.Display): # "Max Range", # "Miscellanea", "attr:maxVelocity", - "Fighter Abilities" - # "Price", + "Fighter Abilities", + "Price", ] def __init__(self, parent): diff --git a/gui/builtinItemStatsViews/itemCompare.py b/gui/builtinItemStatsViews/itemCompare.py index 97a4b953d..3c6ad76ed 100644 --- a/gui/builtinItemStatsViews/itemCompare.py +++ b/gui/builtinItemStatsViews/itemCompare.py @@ -16,7 +16,7 @@ class ItemCompare(wx.Panel): def __init__(self, parent, stuff, item, items, context=None): # Start dealing with Price stuff to get that thread going sPrice = ServicePrice.getInstance() - sPrice.getPrices(items, self.UpdateList) + sPrice.getPrices(items, self.UpdateList, fetchTimeout=90) wx.Panel.__init__(self, parent) mainSizer = wx.BoxSizer(wx.VERTICAL) diff --git a/gui/builtinStatsViews/priceViewFull.py b/gui/builtinStatsViews/priceViewFull.py index 767ad958f..92aea617f 100644 --- a/gui/builtinStatsViews/priceViewFull.py +++ b/gui/builtinStatsViews/priceViewFull.py @@ -22,7 +22,7 @@ import wx from gui.statsView import StatsView from gui.bitmap_loader import BitmapLoader from gui.utils.numberFormatter import formatAmount -from service.price import Price +from service.price import Fit, Price from service.settings import PriceMenuSettings @@ -51,7 +51,7 @@ class PriceViewFull(StatsView): gridPrice = wx.GridSizer(2, 3, 0, 0) contentSizer.Add(gridPrice, 0, wx.EXPAND | wx.ALL, 0) - for _type in ("ship", "fittings", "total", "drones", "cargoBay", "character"): + for _type in ("ship", "fittings", "character", "drones", "cargoBay", "total"): if _type in "ship": image = "ship_big" elif _type in ("fittings", "total"): @@ -79,11 +79,8 @@ class PriceViewFull(StatsView): def refreshPanel(self, fit): if fit is not None: self.fit = fit - - fit_items = Price.fitItemsList(fit) - - sPrice = Price.getInstance() - sPrice.getPrices(fit_items, self.processPrices) + fit_items = set(Fit.fitItemIter(fit)) + Price.getInstance().getPrices(fit_items, self.processPrices, fetchTimeout=30) self.labelEMStatus.SetLabel("Updating prices...") self.refreshPanelPrices(fit) diff --git a/gui/builtinStatsViews/priceViewMinimal.py b/gui/builtinStatsViews/priceViewMinimal.py index 8d6a5ce83..4506f2686 100644 --- a/gui/builtinStatsViews/priceViewMinimal.py +++ b/gui/builtinStatsViews/priceViewMinimal.py @@ -22,7 +22,7 @@ import wx from gui.statsView import StatsView from gui.bitmap_loader import BitmapLoader from gui.utils.numberFormatter import formatAmount -from service.price import Price +from service.price import Fit, Price from service.settings import PriceMenuSettings @@ -73,11 +73,8 @@ class PriceViewMinimal(StatsView): def refreshPanel(self, fit): if fit is not None: self.fit = fit - - fit_items = Price.fitItemsList(fit) - - sPrice = Price.getInstance() - sPrice.getPrices(fit_items, self.processPrices) + fit_items = set(Fit.fitItemIter(fit)) + Price.getInstance().getPrices(fit_items, self.processPrices, fetchTimeout=30) self.labelEMStatus.SetLabel("Updating prices...") self.refreshPanelPrices(fit) diff --git a/gui/builtinViewColumns/price.py b/gui/builtinViewColumns/price.py index 56901c172..563d0b1c0 100644 --- a/gui/builtinViewColumns/price.py +++ b/gui/builtinViewColumns/price.py @@ -22,6 +22,7 @@ import wx from eos.saveddata.cargo import Cargo from eos.saveddata.drone import Drone +from eos.saveddata.fighter import Fighter from eos.saveddata.price import PriceStatus from service.price import Price as ServicePrice from gui.viewColumn import ViewColumn @@ -29,6 +30,20 @@ from gui.bitmap_loader import BitmapLoader from gui.utils.numberFormatter import formatAmount +def formatPrice(stuff, priceObj): + textItems = [] + if priceObj.price: + mult = 1 + if isinstance(stuff, (Drone, Cargo)): + mult = stuff.amount + elif isinstance(stuff, Fighter): + mult = stuff.amountActive + textItems.append(formatAmount(priceObj.price * mult, 3, 3, 9, currency=True)) + if priceObj.status in (PriceStatus.fetchFail, PriceStatus.fetchTimeout): + textItems.append("(!)") + return " ".join(textItems) + + class Price(ViewColumn): name = "Price" @@ -48,35 +63,21 @@ class Price(ViewColumn): priceObj = stuff.item.price - if not priceObj.isValid: + if not priceObj.isValid(): return False - # Fetch actual price as float to not modify its value on Price object - price = priceObj.price - - if price == 0: - return "" - - if isinstance(stuff, Drone) or isinstance(stuff, Cargo): - price *= stuff.amount - - return formatAmount(price, 3, 3, 9, currency=True) + return formatPrice(stuff, priceObj) def delayedText(self, mod, display, colItem): sPrice = ServicePrice.getInstance() def callback(item): - price = item[0] - textItems = [] - if price.price: - textItems.append(formatAmount(price.price, 3, 3, 9, currency=True)) - if price.status == PriceStatus.fail: - textItems.append("(!)") - colItem.SetText(" ".join(textItems)) + priceObj = item[0] + colItem.SetText(formatPrice(mod, priceObj)) display.SetItem(colItem) - sPrice.getPrices([mod.item], callback, True) + sPrice.getPrices([mod.item], callback, waitforthread=True) def getImageId(self, mod): return -1 diff --git a/gui/copySelectDialog.py b/gui/copySelectDialog.py index 91725e048..985b98d01 100644 --- a/gui/copySelectDialog.py +++ b/gui/copySelectDialog.py @@ -26,6 +26,10 @@ import wx from service.port.eft import EFT_OPTIONS from service.port.multibuy import MULTIBUY_OPTIONS from service.settings import SettingsProvider +from service.port import EfsPort, Port +from service.const import PortMultiBuyOptions +from eos.db import getFit +from gui.utils.clipboard import toClipboard class CopySelectDialog(wx.Dialog): @@ -39,6 +43,17 @@ class CopySelectDialog(wx.Dialog): def __init__(self, parent): wx.Dialog.__init__(self, parent, id=wx.ID_ANY, title="Select a format", size=(-1, -1), style=wx.DEFAULT_DIALOG_STYLE) + + self.CopySelectDict = { + CopySelectDialog.copyFormatEft : self.exportEft, + CopySelectDialog.copyFormatXml : self.exportXml, + CopySelectDialog.copyFormatDna : self.exportDna, + CopySelectDialog.copyFormatEsi : self.exportEsi, + CopySelectDialog.copyFormatMultiBuy: self.exportMultiBuy, + CopySelectDialog.copyFormatEfs : self.exportEfs + } + + self.mainFrame = parent mainSizer = wx.BoxSizer(wx.VERTICAL) self.copyFormats = OrderedDict(( @@ -87,8 +102,8 @@ class CopySelectDialog(wx.Dialog): self.options[formatId][optId] = checkbox if self.settings['options'].get(formatId, {}).get(optId, defaultFormatOptions.get(formatId, {}).get(optId)): checkbox.SetValue(True) - bsizer.Add(checkbox, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 3) - mainSizer.Add(bsizer, 1, wx.EXPAND | wx.LEFT, 20) + bsizer.Add(checkbox, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 3) + mainSizer.Add(bsizer, 0, wx.EXPAND | wx.LEFT, 20) buttonSizer = self.CreateButtonSizer(wx.OK | wx.CANCEL) if buttonSizer: @@ -99,6 +114,31 @@ class CopySelectDialog(wx.Dialog): self.Fit() self.Center() + def Validate(self): + # Since this dialog is shown through aa ShowModal(), we hook into the Validate function to veto the closing of the dialog until we're ready. + # This always returns False, and when we're ready will EndModal() + selected = self.GetSelected() + options = self.GetOptions() + + settings = SettingsProvider.getInstance().getSettings("pyfaExport") + settings["format"] = selected + settings["options"] = options + self.waitDialog = None + + def cb(text): + if self.waitDialog: + del self.waitDialog + toClipboard(text) + self.EndModal(wx.ID_OK) + + export_options = options.get(selected) + if selected == CopySelectDialog.copyFormatMultiBuy and export_options.get(PortMultiBuyOptions.OPTIMIZE_PRICES, False): + self.waitDialog = wx.BusyInfo("Optimizing Prices", parent=self) + + self.CopySelectDict[selected](export_options, callback=cb) + + return False + def Selected(self, event): obj = event.GetEventObject() formatName = obj.GetLabel() @@ -119,3 +159,27 @@ class CopySelectDialog(wx.Dialog): for formatId in self.options: options[formatId] = {optId: ch.IsChecked() for optId, ch in self.options[formatId].items()} return options + + def exportEft(self, options, callback): + fit = getFit(self.mainFrame.getActiveFit()) + Port.exportEft(fit, options, callback) + + def exportDna(self, options, callback): + fit = getFit(self.mainFrame.getActiveFit()) + Port.exportDna(fit, callback) + + def exportEsi(self, options, callback): + fit = getFit(self.mainFrame.getActiveFit()) + Port.exportESI(fit, callback) + + def exportXml(self, options, callback): + fit = getFit(self.mainFrame.getActiveFit()) + Port.exportXml(None, fit, callback) + + def exportMultiBuy(self, options, callback): + fit = getFit(self.mainFrame.getActiveFit()) + Port.exportMultiBuy(fit, options, callback) + + def exportEfs(self, options, callback): + fit = getFit(self.mainFrame.getActiveFit()) + EfsPort.exportEfs(fit, 0, callback) \ No newline at end of file diff --git a/gui/fitCommands/__init__.py b/gui/fitCommands/__init__.py index 793431ab7..83b7dbb46 100644 --- a/gui/fitCommands/__init__.py +++ b/gui/fitCommands/__init__.py @@ -36,3 +36,4 @@ from .guiFitRename import GuiFitRenameCommand from .guiChangeImplantLocation import GuiChangeImplantLocation from .guiImportMutatedModule import GuiImportMutatedModuleCommand from .guiSetSpoolup import GuiSetSpoolup +from .guiRebaseItems import GuiRebaseItemsCommand diff --git a/gui/fitCommands/calc/fitRebaseItem.py b/gui/fitCommands/calc/fitRebaseItem.py new file mode 100644 index 000000000..1fb44b266 --- /dev/null +++ b/gui/fitCommands/calc/fitRebaseItem.py @@ -0,0 +1,31 @@ +import wx + +from logbook import Logger + +import eos.db + + +pyfalog = Logger(__name__) + + +class FitRebaseItemCommand(wx.Command): + + def __init__(self, fitID, containerName, position, newTypeID): + wx.Command.__init__(self, True, "Rebase Item") + self.fitID = fitID + self.containerName = containerName + self.position = position + self.newTypeID = newTypeID + self.oldTypeID = None + + def Do(self): + fit = eos.db.getFit(self.fitID) + obj = getattr(fit, self.containerName)[self.position] + self.oldTypeID = getattr(obj.item, "ID", None) + newItem = eos.db.getItem(self.newTypeID) + obj.rebase(newItem) + return True + + def Undo(self): + cmd = FitRebaseItemCommand(self.fitID, self.containerName, self.position, self.oldTypeID) + return cmd.Do() diff --git a/gui/fitCommands/guiAddDrone.py b/gui/fitCommands/guiAddDrone.py index 6549d1e21..3c1bec21e 100644 --- a/gui/fitCommands/guiAddDrone.py +++ b/gui/fitCommands/guiAddDrone.py @@ -8,7 +8,7 @@ from .calc.fitAddDrone import FitAddDroneCommand class GuiAddDroneCommand(wx.Command): def __init__(self, fitID, itemID): - wx.Command.__init__(self, True, "Cargo Add") + wx.Command.__init__(self, True, "Drone Add") self.mainFrame = gui.mainFrame.MainFrame.getInstance() self.sFit = Fit.getInstance() self.internal_history = wx.CommandProcessor() diff --git a/gui/fitCommands/guiAddFighter.py b/gui/fitCommands/guiAddFighter.py index 01024e706..7a69ebc28 100644 --- a/gui/fitCommands/guiAddFighter.py +++ b/gui/fitCommands/guiAddFighter.py @@ -8,7 +8,7 @@ from .calc.fitAddFighter import FitAddFighterCommand class GuiAddFighterCommand(wx.Command): def __init__(self, fitID, itemID): - wx.Command.__init__(self, True, "Cargo Add") + wx.Command.__init__(self, True, "Fighter Add") self.mainFrame = gui.mainFrame.MainFrame.getInstance() self.sFit = Fit.getInstance() self.internal_history = wx.CommandProcessor() diff --git a/gui/fitCommands/guiCargoToModule.py b/gui/fitCommands/guiCargoToModule.py index a2583e47c..d77abbc3b 100644 --- a/gui/fitCommands/guiCargoToModule.py +++ b/gui/fitCommands/guiCargoToModule.py @@ -21,7 +21,7 @@ class GuiCargoToModuleCommand(wx.Command): """ def __init__(self, fitID, moduleIdx, cargoIdx, copy=False): - wx.Command.__init__(self, True, "Module State Change") + wx.Command.__init__(self, True, "Cargo to Module") self.mainFrame = gui.mainFrame.MainFrame.getInstance() self.sFit = Fit.getInstance() self.fitID = fitID diff --git a/gui/fitCommands/guiFillWithModule.py b/gui/fitCommands/guiFillWithModule.py index 0fa962bef..5b7bf95e3 100644 --- a/gui/fitCommands/guiFillWithModule.py +++ b/gui/fitCommands/guiFillWithModule.py @@ -17,7 +17,7 @@ class GuiFillWithModuleCommand(wx.Command): set the charge on the underlying module (requires position) :param position: Optional. The position in fit.modules that we are attempting to set the item to """ - wx.Command.__init__(self, True, "Module Add: {}".format(itemID)) + wx.Command.__init__(self, True, "Module Fill: {}".format(itemID)) self.mainFrame = gui.mainFrame.MainFrame.getInstance() self.sFit = Fit.getInstance() self.fitID = fitID diff --git a/gui/fitCommands/guiModuleToCargo.py b/gui/fitCommands/guiModuleToCargo.py index 66a9f9833..4f5b6bcf8 100644 --- a/gui/fitCommands/guiModuleToCargo.py +++ b/gui/fitCommands/guiModuleToCargo.py @@ -14,7 +14,7 @@ pyfalog = Logger(__name__) class GuiModuleToCargoCommand(wx.Command): def __init__(self, fitID, moduleIdx, cargoIdx, copy=False): - wx.Command.__init__(self, True, "Module State Change") + wx.Command.__init__(self, True, "Module to Cargo") self.mainFrame = gui.mainFrame.MainFrame.getInstance() self.sFit = Fit.getInstance() self.fitID = fitID diff --git a/gui/fitCommands/guiRebaseItems.py b/gui/fitCommands/guiRebaseItems.py new file mode 100644 index 000000000..8a69a78f5 --- /dev/null +++ b/gui/fitCommands/guiRebaseItems.py @@ -0,0 +1,46 @@ +import wx + +import eos.db +import gui.mainFrame +from gui import globalEvents as GE +from service.fit import Fit +from .calc.fitRebaseItem import FitRebaseItemCommand +from .calc.fitSetCharge import FitSetChargeCommand + + +class GuiRebaseItemsCommand(wx.Command): + + def __init__(self, fitID, rebaseMap): + wx.Command.__init__(self, True, "Mass Rebase Item") + self.mainFrame = gui.mainFrame.MainFrame.getInstance() + self.fitID = fitID + self.rebaseMap = rebaseMap + self.internal_history = wx.CommandProcessor() + + def Do(self): + fit = eos.db.getFit(self.fitID) + for mod in fit.modules: + if mod.item is not None and mod.item.ID in self.rebaseMap: + self.internal_history.Submit(FitRebaseItemCommand(self.fitID, "modules", mod.modPosition, self.rebaseMap[mod.item.ID])) + if mod.charge is not None and mod.charge.ID in self.rebaseMap: + self.internal_history.Submit(FitSetChargeCommand(self.fitID, [mod.modPosition], self.rebaseMap[mod.charge.ID])) + for containerName in ("drones", "fighters", "implants", "boosters", "cargo"): + container = getattr(fit, containerName) + for obj in container: + if obj.item is not None and obj.item.ID in self.rebaseMap: + self.internal_history.Submit(FitRebaseItemCommand(self.fitID, containerName, container.index(obj), self.rebaseMap[obj.item.ID])) + if self.internal_history.Commands: + eos.db.commit() + Fit.getInstance().recalc(self.fitID) + wx.PostEvent(self.mainFrame, GE.FitChanged(fitID=self.fitID)) + return True + else: + return False + + def Undo(self): + for _ in self.internal_history.Commands: + self.internal_history.Undo() + eos.db.commit() + Fit.getInstance().recalc(self.fitID) + wx.PostEvent(self.mainFrame, GE.FitChanged(fitID=self.fitID)) + return True diff --git a/gui/fitCommands/guiRemoveCargo.py b/gui/fitCommands/guiRemoveCargo.py index f9aa5872a..54323a30e 100644 --- a/gui/fitCommands/guiRemoveCargo.py +++ b/gui/fitCommands/guiRemoveCargo.py @@ -8,7 +8,7 @@ from .calc.fitRemoveCargo import FitRemoveCargoCommand class GuiRemoveCargoCommand(wx.Command): def __init__(self, fitID, itemID): - wx.Command.__init__(self, True, "Module Charge Add") + wx.Command.__init__(self, True, "Cargo Remove") self.mainFrame = gui.mainFrame.MainFrame.getInstance() self.sFit = Fit.getInstance() self.internal_history = wx.CommandProcessor() diff --git a/gui/fitCommands/guiRemoveDrone.py b/gui/fitCommands/guiRemoveDrone.py index 42e651e3d..0d3f9770d 100644 --- a/gui/fitCommands/guiRemoveDrone.py +++ b/gui/fitCommands/guiRemoveDrone.py @@ -8,7 +8,7 @@ from .calc.fitRemoveDrone import FitRemoveDroneCommand class GuiRemoveDroneCommand(wx.Command): def __init__(self, fitID, position, amount=1): - wx.Command.__init__(self, True, "Cargo Add") + wx.Command.__init__(self, True, "Drone Remove") self.mainFrame = gui.mainFrame.MainFrame.getInstance() self.sFit = Fit.getInstance() self.internal_history = wx.CommandProcessor() diff --git a/gui/fitCommands/guiRemoveFighter.py b/gui/fitCommands/guiRemoveFighter.py index f1b983ec5..c1f283700 100644 --- a/gui/fitCommands/guiRemoveFighter.py +++ b/gui/fitCommands/guiRemoveFighter.py @@ -8,7 +8,7 @@ from .calc.fitRemoveFighter import FitRemoveFighterCommand class GuiRemoveFighterCommand(wx.Command): def __init__(self, fitID, position): - wx.Command.__init__(self, True, "Module Remove") + wx.Command.__init__(self, True, "Fighter Remove") self.mainFrame = gui.mainFrame.MainFrame.getInstance() self.sFit = Fit.getInstance() self.fitID = fitID diff --git a/gui/fitCommands/guiRemoveProjected.py b/gui/fitCommands/guiRemoveProjected.py index 74d1ab308..7fb54e5fc 100644 --- a/gui/fitCommands/guiRemoveProjected.py +++ b/gui/fitCommands/guiRemoveProjected.py @@ -27,7 +27,7 @@ class GuiRemoveProjectedCommand(wx.Command): } def __init__(self, fitID, thing): - wx.Command.__init__(self, True, "Projected Add") + wx.Command.__init__(self, True, "Projected Remove") self.mainFrame = gui.mainFrame.MainFrame.getInstance() self.sFit = Fit.getInstance() self.internal_history = wx.CommandProcessor() diff --git a/gui/fitCommands/guiSetMode.py b/gui/fitCommands/guiSetMode.py index 9639028f9..f7e5e09be 100644 --- a/gui/fitCommands/guiSetMode.py +++ b/gui/fitCommands/guiSetMode.py @@ -8,7 +8,7 @@ from .calc.fitSetMode import FitSetModeCommand class GuiSetModeCommand(wx.Command): def __init__(self, fitID, mode): - wx.Command.__init__(self, True, "Cargo Add") + wx.Command.__init__(self, True, "Mode Set") self.mainFrame = gui.mainFrame.MainFrame.getInstance() self.sFit = Fit.getInstance() self.internal_history = wx.CommandProcessor() diff --git a/gui/mainFrame.py b/gui/mainFrame.py index f7039b73e..3d17f9f65 100644 --- a/gui/mainFrame.py +++ b/gui/mainFrame.py @@ -37,7 +37,6 @@ import config import gui.globalEvents as GE from eos.config import gamedata_date, gamedata_version from eos.db.saveddata.loadDefaultDatabaseValues import DefaultDatabaseValues -from eos.db.saveddata.queries import getFit as db_getFit # import this to access override setting from eos.modifiedAttributeDict import ModifiedAttributeDict from gui import graphFrame @@ -64,11 +63,12 @@ from gui.setEditor import ImplantSetEditorDlg from gui.shipBrowser import ShipBrowser from gui.statsPane import StatsPane from gui.updateDialog import UpdateDialog -from gui.utils.clipboard import fromClipboard, toClipboard +from gui.utils.clipboard import fromClipboard from service.character import Character from service.esi import Esi from service.fit import Fit -from service.port import EfsPort, IPortUser, Port +from service.port import IPortUser, Port +from service.price import Price from service.settings import HTMLExportSettings, SettingsProvider from service.update import Update import gui.fitCommands as cmd @@ -508,6 +508,8 @@ class MainFrame(wx.Frame): self.Bind(wx.EVT_MENU, self.saveCharAs, id=menuBar.saveCharAsId) # Save current character self.Bind(wx.EVT_MENU, self.revertChar, id=menuBar.revertCharId) + # Optimize fit price + self.Bind(wx.EVT_MENU, self.optimizeFitPrice, id=menuBar.optimizeFitPrice) # Browse fittings self.Bind(wx.EVT_MENU, self.eveFittings, id=menuBar.eveFittingsId) @@ -655,6 +657,23 @@ class MainFrame(wx.Frame): sChr.revertCharacter(charID) wx.PostEvent(self, GE.CharListUpdated()) + def optimizeFitPrice(self, event): + fitID = self.getActiveFit() + sFit = Fit.getInstance() + fit = sFit.getFit(fitID) + + if fit: + def updateFitCb(replacementsCheaper): + del self.waitDialog + del self.disablerAll + rebaseMap = {k.ID: v.ID for k, v in replacementsCheaper.items()} + self.command.Submit(cmd.GuiRebaseItemsCommand(fitID, rebaseMap)) + + fitItems = {i for i in Fit.fitItemIter(fit) if i is not fit.ship.item} + self.disablerAll = wx.WindowDisabler() + self.waitDialog = wx.BusyInfo("Please Wait...", parent=self) + Price.getInstance().findCheaperReplacements(fitItems, updateFitCb, fetchTimeout=10) + def AdditionsTabSelect(self, event): selTab = self.additionsSelect.index(event.GetId()) @@ -688,30 +707,6 @@ class MainFrame(wx.Frame): else: self.marketBrowser.search.Focus() - def clipboardEft(self, options): - fit = db_getFit(self.getActiveFit()) - toClipboard(Port.exportEft(fit, options)) - - def clipboardDna(self, options): - fit = db_getFit(self.getActiveFit()) - toClipboard(Port.exportDna(fit)) - - def clipboardEsi(self, options): - fit = db_getFit(self.getActiveFit()) - toClipboard(Port.exportESI(fit)) - - def clipboardXml(self, options): - fit = db_getFit(self.getActiveFit()) - toClipboard(Port.exportXml(None, fit)) - - def clipboardMultiBuy(self, options): - fit = db_getFit(self.getActiveFit()) - toClipboard(Port.exportMultiBuy(fit, options)) - - def clipboardEfs(self, options): - fit = db_getFit(self.getActiveFit()) - toClipboard(EfsPort.exportEfs(fit, 0)) - def importFromClipboard(self, event): clipboard = fromClipboard() activeFit = self.getActiveFit() @@ -728,28 +723,8 @@ class MainFrame(wx.Frame): self._openAfterImport(importData) def exportToClipboard(self, event): - CopySelectDict = {CopySelectDialog.copyFormatEft: self.clipboardEft, - CopySelectDialog.copyFormatXml: self.clipboardXml, - CopySelectDialog.copyFormatDna: self.clipboardDna, - CopySelectDialog.copyFormatEsi: self.clipboardEsi, - CopySelectDialog.copyFormatMultiBuy: self.clipboardMultiBuy, - CopySelectDialog.copyFormatEfs: self.clipboardEfs} - dlg = CopySelectDialog(self) - btnPressed = dlg.ShowModal() - - if btnPressed == wx.ID_OK: - selected = dlg.GetSelected() - options = dlg.GetOptions() - - settings = SettingsProvider.getInstance().getSettings("pyfaExport") - settings["format"] = selected - settings["options"] = options - CopySelectDict[selected](options.get(selected)) - - try: - dlg.Destroy() - except RuntimeError: - pyfalog.error("Tried to destroy an object that doesn't exist in .") + with CopySelectDialog(self) as dlg: + dlg.ShowModal() def exportSkillsNeeded(self, event): """ Exports skills needed for active fit and active character """ diff --git a/gui/mainMenuBar.py b/gui/mainMenuBar.py index c0a28f1dc..413098b61 100644 --- a/gui/mainMenuBar.py +++ b/gui/mainMenuBar.py @@ -28,8 +28,6 @@ import gui.globalEvents as GE from gui.bitmap_loader import BitmapLoader from logbook import Logger -# from service.crest import Crest -# from service.crest import CrestModes pyfalog = Logger(__name__) @@ -59,6 +57,7 @@ class MainMenuBar(wx.MenuBar): self.importDatabaseDefaultsId = wx.NewId() self.toggleIgnoreRestrictionID = wx.NewId() self.devToolsId = wx.NewId() + self.optimizeFitPrice = wx.NewId() # pheonix: evaluate if this is needed if 'wxMac' in wx.PlatformInfo and wx.VERSION >= (3, 0): @@ -101,6 +100,9 @@ class MainMenuBar(wx.MenuBar): editMenu.Append(self.revertCharId, "Revert Character") editMenu.AppendSeparator() self.ignoreRestrictionItem = editMenu.Append(self.toggleIgnoreRestrictionID, "Ignore Fitting Restrictions") + editMenu.AppendSeparator() + editMenu.Append(self.optimizeFitPrice, "Optimize Fit Price") + # Character menu windowMenu = wx.Menu() @@ -134,8 +136,6 @@ class MainMenuBar(wx.MenuBar): preferencesItem.SetBitmap(BitmapLoader.getBitmap("preferences_small", "gui")) windowMenu.Append(preferencesItem) - # self.sEsi = Crest.getInstance() - # CREST Menu esiMMenu = wx.Menu() self.Append(esiMMenu, "EVE &SSO") diff --git a/scripts/jsonToSql.py b/scripts/jsonToSql.py index 913b74b1c..74dc399eb 100755 --- a/scripts/jsonToSql.py +++ b/scripts/jsonToSql.py @@ -178,32 +178,17 @@ def main(db, json_path): def fillReplacements(tables): - def compareAttrs(attrs1, attrs2, attrHig): - """ - Compares received attribute sets. Returns: - - 0 if sets are different - - 1 if sets are exactly the same - - 2 if first set is strictly better - - 3 if second set is strictly better - """ + def compareAttrs(attrs1, attrs2): + # Consider items as different if they have no attrs + if len(attrs1) == 0 and len(attrs2) == 0: + return False if set(attrs1) != set(attrs2): - return 0 + return False if all(attrs1[aid] == attrs2[aid] for aid in attrs1): - return 1 - if all( - (attrs1[aid] >= attrs2[aid] and attrHig[aid]) or - (attrs1[aid] <= attrs2[aid] and not attrHig[aid]) - for aid in attrs1 - ): - return 2 - if all( - (attrs2[aid] >= attrs1[aid] and attrHig[aid]) or - (attrs2[aid] <= attrs1[aid] and not attrHig[aid]) - for aid in attrs1 - ): - return 3 - return 0 + return True + return False + print('finding replacements') skillReqAttribs = { 182: 277, 183: 278, @@ -213,10 +198,17 @@ def main(db, json_path): 1290: 1288} skillReqAttribsFlat = set(skillReqAttribs.keys()).union(skillReqAttribs.values()) # Get data on type groups + # Format: {type ID: group ID} typesGroups = {} for row in tables['evetypes']: typesGroups[row['typeID']] = row['groupID'] + # Get data on item effects + # Format: {type ID: set(effect, IDs)} + typesEffects = {} + for row in tables['dgmtypeeffects']: + typesEffects.setdefault(row['typeID'], set()).add(row['effectID']) # Get data on type attributes + # Format: {type ID: {attribute ID: attribute value}} typesNormalAttribs = {} typesSkillAttribs = {} for row in tables['dgmtypeattribs']: @@ -226,15 +218,23 @@ def main(db, json_path): typeSkillAttribs[row['attributeID']] = row['value'] # Ignore these attributes for comparison purposes elif attributeID in ( + # We do not need mass as it affects final ship stats only when carried by ship itself + # (and we're not going to replace ships), but it's wildly inconsistent for other items, + # which otherwise would be the same + 4, # mass + 124, # mainColor + 162, # radius 422, # techLevel 633, # metaLevel - 1692 # metaGroupID + 1692, # metaGroupID + 1768 # typeColorScheme ): continue else: typeNormalAttribs = typesNormalAttribs.setdefault(row['typeID'], {}) typeNormalAttribs[row['attributeID']] = row['value'] # Get data on skill requirements + # Format: {type ID: {skill type ID: skill level}} typesSkillReqs = {} for typeID, typeAttribs in typesSkillAttribs.items(): typeSkillAttribs = typesSkillAttribs.get(typeID, {}) @@ -248,46 +248,54 @@ def main(db, json_path): except (KeyError, ValueError): continue typeSkillReqs[skillType] = skillLevel - # Get data on attribute highIsGood flag - attrHig = {} - for row in tables['dgmattribs']: - attrHig[row['attributeID']] = bool(row['highIsGood']) + # Format: {group ID: category ID} + groupCategories = {} + for row in tables['evegroups']: + groupCategories[row['groupID']] = row['categoryID'] # As EVE affects various types mostly depending on their group or skill requirements, # we're going to group various types up this way + # Format: {(group ID, frozenset(skillreq, type, IDs), frozenset(type, effect, IDs): [type ID, {attribute ID: attribute value}]} groupedData = {} for row in tables['evetypes']: typeID = row['typeID'] + # Ignore items outside of categories we need + if groupCategories[typesGroups[typeID]] not in ( + 6, # Ship + 7, # Module + 8, # Charge + 18, # Drone + 20, # Implant + 22, # Deployable + 23, # Starbase + 32, # Subsystem + 35, # Decryptors + 65, # Structure + 66, # Structure Module + 87, # Fighter + ): + continue typeAttribs = typesNormalAttribs.get(typeID, {}) - # Ignore stuff w/o attributes + # Ignore items w/o attributes if not typeAttribs: continue # We need only skill types, not levels for keys typeSkillreqs = frozenset(typesSkillReqs.get(typeID, {})) typeGroup = typesGroups[typeID] - groupData = groupedData.setdefault((typeGroup, typeSkillreqs), []) + typeEffects = frozenset(typesEffects.get(typeID, ())) + groupData = groupedData.setdefault((typeGroup, typeSkillreqs, typeEffects), []) groupData.append((typeID, typeAttribs)) - same = {} - better = {} - # Now, go through composed groups and for every item within it find items which are - # the same and which are better + # Format: {type ID: set(type IDs)} + replacements = {} + # Now, go through composed groups and for every item within it + # find items which are the same for groupData in groupedData.values(): for type1, type2 in itertools.combinations(groupData, 2): - comparisonResult = compareAttrs(type1[1], type2[1], attrHig) - # Equal - if comparisonResult == 1: - same.setdefault(type1[0], set()).add(type2[0]) - same.setdefault(type2[0], set()).add(type1[0]) - # First is better - elif comparisonResult == 2: - better.setdefault(type2[0], set()).add(type1[0]) - # Second is better - elif comparisonResult == 3: - better.setdefault(type1[0], set()).add(type2[0]) + if compareAttrs(type1[1], type2[1]): + replacements.setdefault(type1[0], set()).add(type2[0]) + replacements.setdefault(type2[0], set()).add(type1[0]) # Put this data into types table so that normal process hooks it up for row in tables['evetypes']: - typeID = row['typeID'] - row['replaceSame'] = ','.join('{}'.format(tid) for tid in sorted(same.get(typeID, ()))) - row['replaceBetter'] = ','.join('{}'.format(tid) for tid in sorted(better.get(typeID, ()))) + row['replacements'] = ','.join('{}'.format(tid) for tid in sorted(replacements.get(row['typeID'], ()))) data = {} diff --git a/service/const.py b/service/const.py index 990f71784..1f83b38a9 100644 --- a/service/const.py +++ b/service/const.py @@ -56,6 +56,7 @@ class PortMultiBuyOptions(IntEnum): IMPLANTS = 1 CARGO = 2 LOADED_CHARGES = 3 + OPTIMIZE_PRICES = 4 @unique @@ -99,4 +100,4 @@ class GuiAttrGroup(IntEnum): ON_DEATH = auto() JUMP_SYSTEMS = auto() PROPULSIONS = auto() - FIGHTERS = auto() \ No newline at end of file + FIGHTERS = auto() diff --git a/service/fit.py b/service/fit.py index 1aba683e2..c02dd7159 100644 --- a/service/fit.py +++ b/service/fit.py @@ -555,9 +555,26 @@ class Fit(FitDeprecated): changed = True return changed - # If any state was changed, recalculate attributes again - # if changed: - # self.recalc(fit) + + @classmethod + def fitObjectIter(cls, fit): + yield fit.ship + + for mod in fit.modules: + if not mod.isEmpty: + yield mod + + for container in (fit.drones, fit.fighters, fit.implants, fit.boosters, fit.cargo): + for obj in container: + yield obj + + @classmethod + def fitItemIter(cls, fit): + for fitobj in cls.fitObjectIter(fit): + yield fitobj.item + charge = getattr(fitobj, 'charge', None) + if charge: + yield charge def refreshFit(self, fitID): pyfalog.debug("Refresh fit for fit ID: {0}", fitID) diff --git a/service/market.py b/service/market.py index 745909e23..085fef624 100644 --- a/service/market.py +++ b/service/market.py @@ -795,3 +795,20 @@ class Market(object): """Filter items by meta lvl""" filtered = set([item for item in items if self.getMetaGroupIdByItem(item) in metas]) return filtered + + def getReplacements(self, identity): + item = self.getItem(identity) + # We already store needed type IDs in database + replTypeIDs = {int(i) for i in item.replacements.split(",") if i} + if not replTypeIDs: + return () + # As replacements were generated without keeping track which items were published, + # filter them out here + items = [] + for typeID in replTypeIDs: + item = self.getItem(typeID) + if not item: + continue + if self.getPublicityByItem(item): + items.append(item) + return items diff --git a/service/marketSources/evemarketdata.py b/service/marketSources/evemarketdata.py index 15edc92ef..b592c9067 100644 --- a/service/marketSources/evemarketdata.py +++ b/service/marketSources/evemarketdata.py @@ -17,30 +17,37 @@ # along with pyfa. If not, see . # ============================================================================= -import time + from xml.dom import minidom from logbook import Logger from eos.saveddata.price import PriceStatus from service.network import Network -from service.price import Price, TIMEOUT, VALIDITY +from service.price import Price pyfalog = Logger(__name__) -class EveMarketData(object): +class EveMarketData: name = "eve-marketdata.com" - def __init__(self, types, system, priceMap): - data = {} - baseurl = "https://eve-marketdata.com/api/item_prices.xml" - data["system_id"] = system # Use Jita for market - data["type_ids"] = ','.join(str(x) for x in types) + def __init__(self, priceMap, system, fetchTimeout): + # Try selected system first + self.fetchPrices(priceMap, max(2 * fetchTimeout / 3, 2), system) + # If price was not available - try globally + if priceMap: + self.fetchPrices(priceMap, max(fetchTimeout / 3, 2)) + @staticmethod + def fetchPrices(priceMap, fetchTimeout, system=None): + params = {"type_ids": ','.join(str(typeID) for typeID in priceMap)} + if system is not None: + params["system_id"] = system + baseurl = "https://eve-marketdata.com/api/item_prices.xml" network = Network.getInstance() - data = network.request(baseurl, network.PRICES, params=data) + data = network.request(baseurl, network.PRICES, params=params, timeout=fetchTimeout) xml = minidom.parseString(data.text) types = xml.getElementsByTagName("eve").item(0).getElementsByTagName("price") @@ -55,19 +62,10 @@ class EveMarketData(object): pyfalog.warning("Failed to get price for: {0}", type_) continue - # Fill price data - priceobj = priceMap[typeID] - - # eve-marketdata returns 0 if price data doesn't even exist for the item. In this case, don't reset the - # cached price, and set the price timeout to TIMEOUT (every 15 minutes currently). Se GH issue #1334 - if price != 0: - priceobj.price = price - priceobj.time = time.time() + VALIDITY - priceobj.status = PriceStatus.success - else: - priceobj.time = time.time() + TIMEOUT - - # delete price from working dict + # eve-marketdata returns 0 if price data doesn't even exist for the item + if price == 0: + continue + priceMap[typeID].update(PriceStatus.fetchSuccess, price) del priceMap[typeID] diff --git a/service/marketSources/evemarketer.py b/service/marketSources/evemarketer.py index 478de4371..6d85380ab 100644 --- a/service/marketSources/evemarketer.py +++ b/service/marketSources/evemarketer.py @@ -17,33 +17,37 @@ # along with pyfa. If not, see . # ============================================================================= -import time + from xml.dom import minidom from logbook import Logger from eos.saveddata.price import PriceStatus from service.network import Network -from service.price import Price, VALIDITY +from service.price import Price pyfalog = Logger(__name__) -class EveMarketer(object): +class EveMarketer: name = "evemarketer" - def __init__(self, types, system, priceMap): - data = {} + def __init__(self, priceMap, system, fetchTimeout): + # Try selected system first + self.fetchPrices(priceMap, max(2 * fetchTimeout / 3, 2), system) + # If price was not available - try globally + if priceMap: + self.fetchPrices(priceMap, max(fetchTimeout / 3, 2)) + + @staticmethod + def fetchPrices(priceMap, fetchTimeout, system=None): + params = {"typeid": {typeID for typeID in priceMap}} + if system is not None: + params["usesystem"] = system baseurl = "https://api.evemarketer.com/ec/marketstat" - - data["usesystem"] = system # Use Jita for market - data["typeid"] = set() - for typeID in types: # Add all typeID arguments - data["typeid"].add(typeID) - network = Network.getInstance() - data = network.request(baseurl, network.PRICES, params=data) + data = network.request(baseurl, network.PRICES, params=params, timeout=fetchTimeout) xml = minidom.parseString(data.text) types = xml.getElementsByTagName("marketstat").item(0).getElementsByTagName("type") # Cycle through all types we've got from request @@ -56,15 +60,15 @@ class EveMarketer(object): percprice = float(sell.getElementsByTagName("percentile").item(0).firstChild.data) except (TypeError, ValueError): pyfalog.warning("Failed to get price for: {0}", type_) - percprice = 0 + continue - # Fill price data - priceobj = priceMap[typeID] - priceobj.price = percprice - priceobj.time = time.time() + VALIDITY - priceobj.status = PriceStatus.success + # Price is 0 if evemarketer has info on this item, but it is not available + # for current scope limit. If we provided scope limit - make sure to skip + # such items to check globally, and do not skip if requested globally + if percprice == 0 and system is not None: + continue - # delete price from working dict + priceMap[typeID].update(PriceStatus.fetchSuccess, percprice) del priceMap[typeID] diff --git a/service/network.py b/service/network.py index 5554eadec..5ce413445 100644 --- a/service/network.py +++ b/service/network.py @@ -53,7 +53,7 @@ class TimeoutError(Exception): pass -class Network(object): +class Network: # Request constants - every request must supply this, as it is checked if # enabled or not via settings ENABLED = 1 diff --git a/service/port/dna.py b/service/port/dna.py index 27b088e29..9b1a5ecee 100644 --- a/service/port/dna.py +++ b/service/port/dna.py @@ -124,7 +124,7 @@ def importDna(string): return f -def exportDna(fit): +def exportDna(fit, callback): dna = str(fit.shipID) subsystems = [] # EVE cares which order you put these in mods = OrderedDict() @@ -174,4 +174,9 @@ def exportDna(fit): for charge in charges: dna += ":{0};{1}".format(charge, charges[charge]) - return dna + "::" + text = dna + "::" + + if callback: + callback(text) + else: + return text diff --git a/service/port/efs.py b/service/port/efs.py index f4781dfdd..fc00d5dfb 100755 --- a/service/port/efs.py +++ b/service/port/efs.py @@ -576,7 +576,7 @@ class EfsPort: return sizeNotFoundMsg @staticmethod - def exportEfs(fit, typeNotFitFlag): + def exportEfs(fit, typeNotFitFlag, callback): sFit = Fit.getInstance() includeShipTypeData = typeNotFitFlag > 0 if includeShipTypeData: @@ -673,4 +673,8 @@ class EfsPort: pyfalog.error(e) dataDict = {"name": fitName + "Fit could not be correctly parsed"} export = json.dumps(dataDict, skipkeys=True) - return export + + if callback: + callback(export) + else: + return export diff --git a/service/port/eft.py b/service/port/eft.py index b5eba1bc5..e1c15be7e 100644 --- a/service/port/eft.py +++ b/service/port/eft.py @@ -19,7 +19,6 @@ import re -from service.const import PortEftOptions, PortEftRigSize from logbook import Logger @@ -34,6 +33,7 @@ from eos.saveddata.module import Module from eos.saveddata.ship import Ship from eos.saveddata.fit import Fit from eos.const import FittingSlot, FittingModuleState +from service.const import PortEftOptions, PortEftRigSize from service.fit import Fit as svcFit from service.market import Market from service.port.muta import parseMutant, renderMutant @@ -54,7 +54,7 @@ SLOT_ORDER = (FittingSlot.LOW, FittingSlot.MED, FittingSlot.HIGH, FittingSlot.RI OFFLINE_SUFFIX = '/OFFLINE' -def exportEft(fit, options): +def exportEft(fit, options, callback): # EFT formatted export is split in several sections, each section is # separated from another using 2 blank lines. Sections might have several # sub-sections, which are separated by 1 blank line @@ -150,7 +150,12 @@ def exportEft(fit, options): if mutationLines: sections.append('\n'.join(mutationLines)) - return '{}\n\n{}'.format(header, '\n\n\n'.join(sections)) + text = '{}\n\n{}'.format(header, '\n\n\n'.join(sections)) + + if callback: + callback(text) + else: + return text def importEft(lines): diff --git a/service/port/esi.py b/service/port/esi.py index 80a940c88..b97e9d20a 100644 --- a/service/port/esi.py +++ b/service/port/esi.py @@ -55,7 +55,7 @@ INV_FLAG_DRONEBAY = 87 INV_FLAG_FIGHTER = 158 -def exportESI(ofit): +def exportESI(ofit, callback): # A few notes: # max fit name length is 50 characters # Most keys are created simply because they are required, but bogus data is okay @@ -135,7 +135,12 @@ def exportESI(ofit): if len(fit['items']) == 0: raise ESIExportException("Cannot export fitting: module list cannot be empty.") - return json.dumps(fit) + text = json.dumps(fit) + + if callback: + callback(text) + else: + return text def importESI(string): diff --git a/service/port/multibuy.py b/service/port/multibuy.py index bc63ec6b1..fbb099a6d 100644 --- a/service/port/multibuy.py +++ b/service/port/multibuy.py @@ -19,52 +19,76 @@ from service.const import PortMultiBuyOptions +from service.price import Price as sPrc + MULTIBUY_OPTIONS = ( (PortMultiBuyOptions.LOADED_CHARGES.value, 'Loaded Charges', 'Export charges loaded into modules', True), (PortMultiBuyOptions.IMPLANTS.value, 'Implants && Boosters', 'Export implants and boosters', False), (PortMultiBuyOptions.CARGO.value, 'Cargo', 'Export cargo contents', True), + (PortMultiBuyOptions.OPTIMIZE_PRICES.value, 'Optimize Prices', 'Replace items by cheaper alternatives', False), ) -def exportMultiBuy(fit, options): - itemCounts = {} - - def addItem(item, quantity=1): - if item not in itemCounts: - itemCounts[item] = 0 - itemCounts[item] += quantity +def exportMultiBuy(fit, options, callback): + itemAmounts = {} for module in fit.modules: if module.item: # Mutated items are of no use for multibuy if module.isMutated: continue - addItem(module.item) + _addItem(itemAmounts, module.item) if module.charge and options[PortMultiBuyOptions.LOADED_CHARGES.value]: - addItem(module.charge, module.numCharges) + _addItem(itemAmounts, module.charge, module.numCharges) for drone in fit.drones: - addItem(drone.item, drone.amount) + _addItem(itemAmounts, drone.item, drone.amount) for fighter in fit.fighters: - addItem(fighter.item, fighter.amountActive) + _addItem(itemAmounts, fighter.item, fighter.amountActive) if options[PortMultiBuyOptions.CARGO.value]: for cargo in fit.cargo: - addItem(cargo.item, cargo.amount) + _addItem(itemAmounts, cargo.item, cargo.amount) if options[PortMultiBuyOptions.IMPLANTS.value]: for implant in fit.implants: - addItem(implant.item) + _addItem(itemAmounts, implant.item) for booster in fit.boosters: - addItem(booster.item) + _addItem(itemAmounts, booster.item) + if options[PortMultiBuyOptions.OPTIMIZE_PRICES.value]: + + def formatCheaperExportCb(replacementsCheaper): + updatedAmounts = {} + for item, itemAmount in itemAmounts.items(): + _addItem(updatedAmounts, replacementsCheaper.get(item, item), itemAmount) + string = _prepareString(fit.ship.item, updatedAmounts) + callback(string) + + priceSvc = sPrc.getInstance() + priceSvc.findCheaperReplacements(itemAmounts, formatCheaperExportCb) + else: + string = _prepareString(fit.ship.item, itemAmounts) + if callback: + callback(string) + else: + return string + + +def _addItem(container, item, quantity=1): + if item not in container: + container[item] = 0 + container[item] += quantity + + +def _prepareString(shipItem, itemAmounts): exportLines = [] - exportLines.append(fit.ship.item.name) - for item in sorted(itemCounts, key=lambda i: (i.group.category.name, i.group.name, i.name)): - count = itemCounts[item] + exportLines.append(shipItem.name) + for item in sorted(itemAmounts, key=lambda i: (i.group.category.name, i.group.name, i.name)): + count = itemAmounts[item] if count == 1: exportLines.append(item.name) else: diff --git a/service/port/port.py b/service/port/port.py index 69c512646..1f961f6c1 100644 --- a/service/port/port.py +++ b/service/port/port.py @@ -257,8 +257,8 @@ class Port(object): return importEftCfg(shipname, lines, iportuser) @classmethod - def exportEft(cls, fit, options): - return exportEft(fit, options) + def exportEft(cls, fit, options, callback=None): + return exportEft(fit, options, callback=callback) # DNA-related methods @staticmethod @@ -266,8 +266,8 @@ class Port(object): return importDna(string) @staticmethod - def exportDna(fit): - return exportDna(fit) + def exportDna(fit, callback=None): + return exportDna(fit, callback=callback) # ESI-related methods @staticmethod @@ -275,8 +275,8 @@ class Port(object): return importESI(string) @staticmethod - def exportESI(fit): - return exportESI(fit) + def exportESI(fit, callback=None): + return exportESI(fit, callback=callback) # XML-related methods @staticmethod @@ -284,10 +284,10 @@ class Port(object): return importXml(text, iportuser) @staticmethod - def exportXml(iportuser=None, *fits): - return exportXml(iportuser, *fits) + def exportXml(iportuser=None, callback=None, *fits): + return exportXml(iportuser, callback=callback, *fits) # Multibuy-related methods @staticmethod - def exportMultiBuy(fit, options): - return exportMultiBuy(fit, options) + def exportMultiBuy(fit, options, callback=None): + return exportMultiBuy(fit, options, callback=callback) diff --git a/service/port/xml.py b/service/port/xml.py index 39fce6994..0e1f1e9ed 100644 --- a/service/port/xml.py +++ b/service/port/xml.py @@ -226,14 +226,13 @@ def importXml(text, iportuser): return fit_list -def exportXml(iportuser, *fits): +def exportXml(iportuser, callback, *fits): doc = xml.dom.minidom.Document() fittings = doc.createElement("fittings") # fit count fit_count = len(fits) fittings.setAttribute("count", "%s" % fit_count) doc.appendChild(fittings) - sFit = svcFit.getInstance() for i, fit in enumerate(fits): try: @@ -324,4 +323,9 @@ def exportXml(iportuser, *fits): iportuser, IPortUser.PROCESS_EXPORT | IPortUser.ID_UPDATE, (i, "convert to xml (%s/%s) %s" % (i + 1, fit_count, fit.ship.name)) ) - return doc.toprettyxml() + text = doc.toprettyxml() + + if callback: + callback(text) + else: + return text diff --git a/service/price.py b/service/price.py index 3028b74ad..5cfdf09c8 100644 --- a/service/price.py +++ b/service/price.py @@ -18,28 +18,26 @@ # ============================================================================= +import math import queue import threading -import time +from itertools import chain import wx from logbook import Logger from eos import db from eos.saveddata.price import PriceStatus +from gui.fitCommands.guiRebaseItems import GuiRebaseItemsCommand from service.fit import Fit from service.market import Market from service.network import TimeoutError + pyfalog = Logger(__name__) -VALIDITY = 24 * 60 * 60 # Price validity period, 24 hours -REREQUEST = 4 * 60 * 60 # Re-request delay for failed fetches, 4 hours -TIMEOUT = 15 * 60 # Network timeout delay for connection issues, 15 minutes - - -class Price(object): +class Price: instance = None systemsList = { @@ -69,38 +67,34 @@ class Price(object): return cls.instance @classmethod - def fetchPrices(cls, prices): + def fetchPrices(cls, prices, fetchTimeout, validityOverride): """Fetch all prices passed to this method""" # Dictionary for our price objects priceMap = {} - # Check all provided price objects, and add invalid ones to dictionary + # Check all provided price objects, and add those we want to update to + # dictionary for price in prices: - if not price.isValid: + if not price.isValid(validityOverride): priceMap[price.typeID] = price - if len(priceMap) == 0: + if not priceMap: return - # Set of items which are still to be requested from this service - toRequest = set() - # Compose list of items we're going to request for typeID in tuple(priceMap): # Get item object item = db.getItem(typeID) - # We're not going to request items only with market group, as eve-central - # doesn't provide any data for items not on the market + # We're not going to request items only with market group, as our current market + # sources do not provide any data for items not on the market if item is None: continue if not item.marketGroupID: - priceMap[typeID].status = PriceStatus.notSupported + priceMap[typeID].update(PriceStatus.notSupported) del priceMap[typeID] continue - toRequest.add(typeID) - # Do not waste our time if all items are not on the market - if len(toRequest) == 0: + if not priceMap: return sFit = Fit.getInstance() @@ -110,62 +104,46 @@ class Price(object): return # attempt to find user's selected price source, otherwise get first one - sourcesToTry = list(cls.sources.keys()) - curr = sFit.serviceFittingOptions["priceSource"] if sFit.serviceFittingOptions["priceSource"] in sourcesToTry else sourcesToTry[0] + sourceAll = list(cls.sources.keys()) + sourcePrimary = sFit.serviceFittingOptions["priceSource"] if sFit.serviceFittingOptions["priceSource"] in sourceAll else sourceAll[0] - while len(sourcesToTry) > 0: - sourcesToTry.remove(curr) + # Format: {source name: timeout weight} + sources = {sourcePrimary: len(sourceAll)} + for source in sourceAll: + if source == sourcePrimary: + continue + sources[source] = min(sources.values()) - 1 + timeoutWeightMult = fetchTimeout / sum(sources.values()) + + # Record timeouts as it will affect our final decision + timedOutSources = {} + + for source, timeoutWeight in sources.items(): + pyfalog.info('Trying {}'.format(source)) + timedOutSources[source] = False + sourceFetchTimeout = timeoutWeight * timeoutWeightMult try: - sourceCls = cls.sources.get(curr) - sourceCls(toRequest, cls.systemsList[sFit.serviceFittingOptions["priceSystem"]], priceMap) - break - # If getting or processing data returned any errors + sourceCls = cls.sources.get(source) + sourceCls(priceMap, cls.systemsList[sFit.serviceFittingOptions["priceSystem"]], sourceFetchTimeout) except TimeoutError: - # Timeout error deserves special treatment - pyfalog.warning("Price fetch timout") - for typeID in tuple(priceMap): - priceobj = priceMap[typeID] - priceobj.time = time.time() + TIMEOUT - priceobj.status = PriceStatus.fail - del priceMap[typeID] - except Exception as ex: - # something happened, try another source - pyfalog.warn('Failed to fetch prices from price source {}: {}'.format(curr, ex, sourcesToTry[0])) - if len(sourcesToTry) > 0: - pyfalog.warn('Trying {}'.format(sourcesToTry[0])) - curr = sourcesToTry[0] + pyfalog.warning("Price fetch timeout for source {}".format(source)) + timedOutSources[source] = True + except Exception as e: + pyfalog.warn('Failed to fetch prices from price source {}: {}'.format(source, e)) + # Sources remove price map items as they fetch info, if none remain then we're done + if not priceMap: + break - # if we get to this point, then we've got an error in all of our sources. Set to REREQUEST delay - for typeID in priceMap.keys(): - priceobj = priceMap[typeID] - priceobj.time = time.time() + REREQUEST - priceobj.status = PriceStatus.fail - - @classmethod - def fitItemsList(cls, fit): - # Compose a list of all the data we need & request it - fit_items = [fit.ship.item] - - for mod in fit.modules: - if not mod.isEmpty: - fit_items.append(mod.item) - - for drone in fit.drones: - fit_items.append(drone.item) - - for fighter in fit.fighters: - fit_items.append(fighter.item) - - for cargo in fit.cargo: - fit_items.append(cargo.item) - - for boosters in fit.boosters: - fit_items.append(boosters.item) - - for implants in fit.implants: - fit_items.append(implants.item) - - return list(set(fit_items)) + # If we get to this point, then we've failed to get price with all our sources + # If all sources failed due to timeouts, set one status + if all(to is True for to in timedOutSources.values()): + for typeID in priceMap.keys(): + priceMap[typeID].update(PriceStatus.fetchTimeout) + # If some sources failed due to any other reason, then it's definitely not network + # timeout and we just set another status + else: + for typeID in priceMap.keys(): + priceMap[typeID].update(PriceStatus.fetchFail) def getPriceNow(self, objitem): """Get price for provided typeID""" @@ -174,7 +152,7 @@ class Price(object): return item.price.price - def getPrices(self, objitems, callback, waitforthread=False): + def getPrices(self, objitems, callback, fetchTimeout=30, waitforthread=False, validityOverride=None): """Get prices for multiple typeIDs""" requests = [] sMkt = Market.getInstance() @@ -186,7 +164,7 @@ class Price(object): try: callback(requests) except Exception as e: - pyfalog.critical("Callback failed.") + pyfalog.critical("Execution of callback from getPrices failed.") pyfalog.critical(e) db.commit() @@ -194,12 +172,43 @@ class Price(object): if waitforthread: self.priceWorkerThread.setToWait(requests, cb) else: - self.priceWorkerThread.trigger(requests, cb) + self.priceWorkerThread.trigger(requests, cb, fetchTimeout, validityOverride) def clearPriceCache(self): pyfalog.debug("Clearing Prices") db.clearPrices() + def findCheaperReplacements(self, items, callback, fetchTimeout=10): + sMkt = Market.getInstance() + + replacementsAll = {} # All possible item replacements + for item in items: + if item in replacementsAll: + continue + itemRepls = sMkt.getReplacements(item) + if itemRepls: + replacementsAll[item] = itemRepls + itemsToFetch = {i for i in chain(replacementsAll.keys(), *replacementsAll.values())} + + def makeCheapMapCb(requests): + # Decide what we are going to replace + replacementsCheaper = {} # Items which should be replaced + for replacee, replacers in replacementsAll.items(): + replacer = min(replacers, key=lambda i: i.price.price or math.inf) + if (replacer.price.price or math.inf) < (replacee.price.price or math.inf): + replacementsCheaper[replacee] = replacer + try: + callback(replacementsCheaper) + except Exception as e: + pyfalog.critical("Execution of callback from findCheaperReplacements failed.") + pyfalog.critical(e) + + # Prices older than 2 hours have to be refetched + validityOverride = 2 * 60 * 60 + self.getPrices(itemsToFetch, makeCheapMapCb, fetchTimeout=fetchTimeout, validityOverride=validityOverride) + + + class PriceWorkerThread(threading.Thread): @@ -214,11 +223,11 @@ class PriceWorkerThread(threading.Thread): queue = self.queue while True: # Grab our data - callback, requests = queue.get() + callback, requests, fetchTimeout, validityOverride = queue.get() # Grab prices, this is the time-consuming part if len(requests) > 0: - Price.fetchPrices(requests) + Price.fetchPrices(requests, fetchTimeout, validityOverride) wx.CallAfter(callback) queue.task_done() @@ -230,14 +239,13 @@ class PriceWorkerThread(threading.Thread): for callback in callbacks: wx.CallAfter(callback) - def trigger(self, prices, callbacks): - self.queue.put((callbacks, prices)) + def trigger(self, prices, callbacks, fetchTimeout, validityOverride): + self.queue.put((callbacks, prices, fetchTimeout, validityOverride)) def setToWait(self, prices, callback): - for x in prices: - if x.typeID not in self.wait: - self.wait[x.typeID] = [] - self.wait[x.typeID].append(callback) + for price in prices: + callbacks = self.wait.setdefault(price.typeID, []) + callbacks.append(callback) # Import market sources only to initialize price source modules, they register on their own