from __future__ import absolute_import
from collections import defaultdict
import datetime as dt
import time
from decimal import Decimal
from functools import partial
import re
import sys
import six
from .util import unistr, urlparse, urlunparse, utc
from flask import Markup
import requests
from sqlalchemy import create_engine, Table, MetaData
from sqlalchemy.sql import select
from . import ships, systems
if six.PY3:
unicode = str
@unistr
[docs]class Killmail(object):
"""Base killmail representation.
.. py:attribute:: kill_id
The ID integer of this killmail. Used by most killboards and by CCP to
refer to killmails.
.. py:attribute:: ship_id
The typeID integer of for the ship lost for this killmail.
.. py:attribute:: ship
The human readable name of the ship lost for this killmail.
.. py:attribute:: ship_url
This is an optional atribute for subclasses to implement. It's intended
to be used for requests to link to a custom, possibly external,
ship-specific page.
.. py:attribute:: pilot_id
The ID number of the pilot who lost the ship. Referred to by CCP as
``characterID``.
.. py:attribute:: pilot
The name of the pilot who lost the ship.
.. py:attribute:: corp_id
The ID number of the corporation :py:attr:`pilot` belonged to at the
time this kill happened.
.. py:attribute:: corp
The name of the corporation referred to by :py:attr:`corp_id`.
.. py:attribute:: alliance_id
The ID number of the alliance :py:attr:`corp` belonged to at the time
of this kill, or ``None`` if the corporation wasn't in an alliance at
the time.
.. py:attribute:: alliance
The name of the alliance referred to by :py:attr:`alliance_id`.
.. py:attribute:: url
A URL for viewing this killmail's information later. Typically an
online killboard such as `zKillboard <https://zkillboard.com>`, but
other kinds of links may be used.
.. py:attribute:: value
The extimated ISK loss for the ship destroyed in this killmail. This is
an optional attribute, and is ``None`` if unsupported. If this
attribute is set, it should be a :py:class:`~.Decimal` or a type that
can be used as the value for the Decimal constructor.
.. py:attribute:: timestamp
The date and time that this kill occured as a
:py:class:`datetime.datetime` object (with a UTC timezone).
.. py:attribute:: verified
Whether or not this killmail has been API verified (or more accurately,
if it is to be trusted when making a
:py:class:`~evesrp.models.Request`.
.. py:attribute:: system
.. py:attribute:: system_id
.. py:attribute:: constellation
.. py:attribute:: region
The system/constellation/region where the kill occured.
"""
[docs] def __init__(self, **kwargs):
"""Initialize a :py:class:`Killmail` with ``None`` for all attributes.
All subclasses of this class, (and all mixins designed to be used with
it) must call ``super().__init__(**kwargs)`` to ensure all
initialization is done.
:param: keyword arguments corresponding to attributes.
"""
self._data = defaultdict(lambda: None)
for attr in (u'kill_id', u'ship_id', u'ship', u'pilot_id', u'pilot',
u'corp_id', u'corp', u'alliance_id', u'alliance', u'verified',
u'url', u'value', u'timestamp', u'system', u'constellation',
u'region', u'system_id'):
try:
setattr(self, attr, kwargs[attr])
except KeyError:
pass
else:
del kwargs[attr]
super(Killmail, self).__init__(**kwargs)
# Any attribute not starting with an underscore will now be stored in a
# separate, private attribute. This is to allow attribute on Killmail to be
# redfined as a property.
def __getattr__(self, name):
try:
return self._data[name]
except KeyError as e:
raise AttributeError(unicode(e))
def __setattr__(self, name, value):
if name[0] == '_':
object.__setattr__(self, name, value)
else:
self._data[name] = value
def __unicode__(self):
return u"{kill_id}: {pilot} lost a {ship}. Verified: {verified}.".\
format(kill_id=self.kill_id, pilot=self.pilot, ship=self.ship,
verified=self.verified)
[docs] def __iter__(self):
"""Iterate over the attributes of this killmail.
Yields tuples in the form ``('<name>', <value>)``. This is used by
:py:meth:`evesrp.models.Request.__init__` to initialize its data
quickly. The `<name>` in the returned tuples is the name of the
attribute on the :py:class:`~evesrp.models.Request`.
"""
yield ('id', self.kill_id)
yield ('ship_type', self.ship)
yield ('corporation', self.corp)
yield ('alliance', self.alliance)
yield ('killmail_url', self.url)
yield ('base_payout', self.value)
yield ('kill_timestamp', self.timestamp)
yield ('system', self.system)
yield ('constellation', self.constellation)
yield ('region', self.region)
#: A user-facing description of what killmails this Killmail
#: validates/handles.
description = (u"A generic Killmail. If you see this text, you need to"
u"reconfigure your application.")
[docs]class ShipNameMixin(object):
"""Killmail mixin providing :py:attr:`Killmail.ship` from
:py:attr:`Killmail.ship_id`.
"""
@property
[docs] def ship(self):
"""Looks up the ship name using :py:attr:`Killmail.ship_id`.
"""
return ships.ships[self.ship_id]
[docs]class LocationMixin(object):
"""Killmail mixin for providing solar system, constellation and region
names from :py:attr:`Killmail.system_id`.
"""
@property
[docs] def system(self):
"""Provides the solar system name using :py:attr:`Killmail.system_id`.
"""
return systems.system_names[self.system_id]
@property
[docs] def constellation(self):
"""Provides the constellation name using :py:attr:`Killmail.system_id`.
"""
return systems.systems_constellations[self.system]
@property
[docs] def region(self):
"""Provides the region name using :py:attr:`Killmail.system_id`.
"""
return systems.constellations_regions[self.constellation]
[docs]class RequestsSessionMixin(object):
"""Mixin for providing a :py:class:`requests.Session`.
The shared session allows HTTP user agents to be set properly, and for
possible connection pooling.
.. py:attribute:: requests_session
A :py:class:`~requests.Session` for making HTTP requests.
"""
[docs] def __init__(self, requests_session=None, **kwargs):
"""Set up a :py:class:`~requests.Session` for making HTTP requests.
If an existing session is not provided, one will be created.
:param requests_session: an existing session to use.
:type requests: :py:class:`~requests.Session`
"""
if requests_session is None:
self.requests_session = requests.Session()
else:
self.requests_session = requests_session
super(RequestsSessionMixin, self).__init__(**kwargs)
[docs]class ZKillmail(Killmail, RequestsSessionMixin, ShipNameMixin, LocationMixin):
"""A killmail sourced from a zKillboard based killboard."""
zkb_regex = re.compile(r'/(detail|kill)/(?P<kill_id>\d+)/?')
[docs] def __init__(self, url, **kwargs):
"""Create a killmail from the given URL.
:param str url: The URL of the killmail.
:raises ValueError: if ``url`` isn't a valid zKillboard killmail.
:raises LookupError: if the zKillboard API response is in an unexpected
format.
"""
super(ZKillmail, self).__init__(**kwargs)
self.url = url
match = self.zkb_regex.search(url)
if match:
self.kill_id = int(match.group('kill_id'))
else:
raise ValueError(u"'{}' is not a valid zKillboard killmail".
format(self.url))
parsed = urlparse(self.url, scheme='https')
if parsed.netloc == '':
# Just in case someone is silly and gives an address without a
# scheme. Also fix self.url to have a scheme.
parsed = urlparse('//' + url, scheme='https')
self.url = parsed.geturl()
self.domain = parsed.netloc
# Check API
api_url = [a for a in parsed]
api_url[2] = '/api/no-attackers/no-items/killID/{}'.format(
self.kill_id)
resp = self.requests_session.get(urlunparse(api_url))
retrieval_error = LookupError(u"Error retrieving killmail data: {}"
.format(resp.status_code))
if resp.status_code != 200:
raise retrieval_error
try:
json = resp.json()
except ValueError as e:
raise retrieval_error
try:
json = json[0]
except IndexError as e:
raise LookupError(u"Invalid killmail: {}".format(url))
# JSON is defined to be UTF-8 in the standard
victim = json[u'victim']
self.pilot_id = int(victim[u'characterID'])
self.pilot = victim[u'characterName']
self.corp_id = int(victim[u'corporationID'])
self.corp = victim[u'corporationName']
if victim[u'allianceID'] != '0':
self.alliance_id = int(victim[u'allianceID'])
self.alliance = victim[u'allianceName']
self.ship_id = int(victim[u'shipTypeID'])
self.system_id = int(json[u'solarSystemID'])
# For consistency, store self.value in millions. Decimal is being used
# for precision at large values.
# Old versions of zKB don't give the ISK value
try:
self.value = Decimal(json[u'zkb'][u'totalValue'])
except KeyError:
self.value = Decimal(0)
# Parse the timestamp
time_struct = time.strptime(json[u'killTime'], '%Y-%m-%d %H:%M:%S')
self.timestamp = dt.datetime(*(time_struct[0:6]),
tzinfo=utc)
@property
def verified(self):
# zKillboard assigns unverified IDs negative numbers
return self.kill_id > 0
def __unicode__(self):
parent = super(ZKillmail, self).__unicode__()
return u"{parent} From ZKillboard at {url}".format(parent=parent,
url=self.url)
description = Markup(u'A link to a lossmail from <a href="https://'
u'zkillboard.com/">ZKillboard</a>.')
[docs]class CRESTMail(Killmail, RequestsSessionMixin, LocationMixin):
"""A killmail with data sourced from a CREST killmail link."""
crest_regex = re.compile(r'/killmails/(?P<kill_id>\d+)/[0-9a-f]+/')
[docs] def __init__(self, url, **kwargs):
"""Create a killmail from a CREST killmail link.
:param str url: the CREST killmail URL.
:raises ValueError: if ``url`` is not a CREST URL.
:raises LookupError: if the CREST API response is in an unexpected
format.
"""
super(CRESTMail, self).__init__(**kwargs)
self.url = url
match = self.crest_regex.search(self.url)
if match:
self.kill_id = match.group('kill_id')
else:
raise ValueError(u"'{}' is not a valid CREST killmail".
format(self.url))
parsed = urlparse(self.url, scheme='https')
if parsed.netloc == '':
parsed = urlparse('//' + url, scheme='https')
self.url = parsed.geturl()
# Check if it's a valid CREST URL
resp = self.requests_session.get(self.url)
# JSON responses are defined to be UTF-8 encoded
if resp.status_code != 200:
raise LookupError(u"Error retrieving CREST killmail: {}".format(
resp.json()[u'message']))
try:
json = resp.json()
except ValueError as e:
raise LookupError(u"Error retrieving killmail data: {}"
.format(resp.status_code))
victim = json[u'victim']
char = victim[u'character']
corp = victim[u'corporation']
ship = victim[u'shipType']
alliance = victim[u'alliance']
self.pilot_id = char[u'id']
self.pilot = char[u'name']
self.corp_id = corp[u'id']
self.corp = corp[u'name']
self.alliance_id = alliance[u'id']
self.alliance = alliance[u'name']
self.ship_id = ship[u'id']
self.ship = ship[u'name']
solarSystem = json[u'solarSystem']
self.system_id = solarSystem[u'id']
self.system = solarSystem[u'name']
# CREST Killmails are always verified
self.verified = True
# Parse the timestamp
time_struct = time.strptime(json[u'killTime'], '%Y.%m.%d %H:%M:%S')
self.timestamp = dt.datetime(*(time_struct[0:6]),
tzinfo=utc)
description = Markup(u'A CREST external killmail link. <a href="#">'
u'How to get one.</a>')