Source code for evesrp.models

from __future__ import absolute_import
import datetime as dt
from decimal import Decimal
import locale
import six
from six.moves import filter, map, range
from sqlalchemy.types import DateTime
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.hybrid import hybrid_property
from flask import Markup

from . import db
from .util import DeclEnum, classproperty, AutoID, Timestamped, AutoName,\
        unistr, ensure_unicode
from .auth import PermissionType


if six.PY3:
    unicode = str


class PrettyDecimal(Decimal):
    """:py:class:`~.Decimal` subclass that can pretty-print itself."""

    def currency(self, commas=True):
        """Format the Decimal as a currency number.

        Commas for the thousands separators, full stops for the decimal, two
        decimal places. Commas are optional and controlled by the ``commas``
        argument.

        Adapted from https://docs.python.org/3.3/library/decimal.html#recipes
        """
        sign, digits, exp = self.quantize(Decimal('0.01')).as_tuple()
        digits = list(map(unicode, digits))
        result = []
        for i in range(2):
            result.append(digits.pop() if digits else u'0')
        result.append(u'.')
        if not digits:
            result.append(u'0')
        count = 0
        while digits:
            result.append(digits.pop())
            count += 1
            if count == 3 and digits and commas:
                count = 0
                result.append(u',')
        result.append(u'-' if sign else u'')
        return u''.join(reversed(result))


class PrettyNumeric(db.TypeDecorator):
    """Type Decorator for :py:class:`~.Numeric` that reformats the userland
    values into :py:class:`PrettyDecimal`\s.
    """

    impl = db.Numeric

    def process_result_value(self, value, dialect):
        return PrettyDecimal(value) if value is not None else None


class ActionType(DeclEnum):

    # The actual stored values are single character to make it easier on
    # engines that don't support native enum types.

    #: Status for a request being evaluated.
    evaluating = u'evaluating', u'Evaluating'

    #: Status for a request that has been evaluated and is awaitng payment.
    approved = u'approved', u'Approved'

    #: Status for a request that has been paid. This is a terminatint state.
    paid = u'paid', u'Paid'

    #: Status for a requests that has been rejected. This is a terminating
    #: state.
    rejected = u'rejected', u'Rejected'

    #: Status for a request that is missing details and needs further action.
    incomplete = u'incomplete', u'Incomplete'

    #: A special type of :py:class:`Action` representing a comment made on the
    #: request.
    comment = u'comment', u'Comment'

    @classproperty
    def finalized(cls):
        return frozenset((cls.paid, cls.rejected))

    @classproperty
    def pending(cls):
        return frozenset((cls.evaluating, cls.approved, cls.incomplete))

    @classproperty
    def statuses(cls):
        return frozenset((cls.evaluating, cls.approved, cls.paid, cls.rejected,
                cls.incomplete))


class ActionError(ValueError):
    """Error raised for invalid state changes for a :py:class:`Request`."""
    pass


class ModifierError(ValueError):
    """Error raised when a modification is attempted to a :py:class:`Request`
    when it's in an invalid state.
    """
    pass


[docs]class Action(db.Model, AutoID, Timestamped, AutoName): """Actions change the state of a Request. With the exception of the comment action (which does nothing), actions change the state of a Request. """ #: The action be taken. See :py:class:`ActionType` for possible values. type_ = db.Column(ActionType.db_type(), nullable=False) #: The ID of the :py:class:`Request` this action applies to. request_id = db.Column(db.Integer, db.ForeignKey('request.id')) #: The :py:class:`Request` this action applies to. request = db.relationship('Request', back_populates='actions') #: The ID of the :py:class:`~.User` who made this action. user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) #: The :py:class:`~.User` who made this action. user = db.relationship('User', back_populates='actions') #: Any additional notes for this action. note = db.Column(db.Text(convert_unicode=True)) def __init__(self, request, user, note=None, type_=None): if type_ is not None: self.type_ = type_ self.user = user self.note = ensure_unicode(note) self.timestamp = dt.datetime.utcnow() self.request = request @db.validates('type_') def set_request_type(self, attr, type_): if self.request is not None: if type_ != ActionType.comment and self.timestamp >=\ self.request.actions[0].timestamp: self.request.status = type_ return type_ def __repr__(self): return "{x.__class__.__name__}({x.request}, {x.user}, {x.type_})".\ format(x=self)
[docs]class Modifier(db.Model, AutoID, Timestamped, AutoName): """Modifiers apply bonuses or penalties to Requests. This is an abstract base class for the pair of concrete implementations. Modifiers can be voided at a later date. The user who voided a modifier and when they did are recorded. """ #: Discriminator column for SQLAlchemy _type = db.Column(db.String(20, convert_unicode=True), nullable=False) #: The ID of the :py:class:`Request` this modifier applies to. request_id = db.Column(db.Integer, db.ForeignKey('request.id')) #: The :py:class:`Request` this modifier applies to. request = db.relationship('Request', back_populates='modifiers') #: The ID of the :py:class`~.User` who added this modifier. user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) #: The :py:class:`~.User` who added this modifier. user = db.relationship('User', foreign_keys=[user_id]) #: Any notes explaining this modification. note = db.Column(db.Text(convert_unicode=True)) #: The ID of the :py:class:`~.User` who voided this modifier (if voided). voided_user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) #: The :py:class:`~.User` who voided this modifier if it has been voided. voided_user = db.relationship('User', foreign_keys=[voided_user_id]) #: If this modifier has been voided, this will be the timestamp of when it #: was voided. voided_timestamp = db.Column(DateTime) @hybrid_property def voided(self): """Boolean of whether this modifier has been voided or not.""" return self.voided_user is not None and \ self.voided_timestamp is not None @voided.expression def voided(cls): return db.and_( cls.voided_user_id != None, cls.voided_timestamp != None ) @declared_attr def __mapper_args__(cls): """SQLAlchemy late-binding attribute to set mapper arguments. Obviates subclasses from having to specify polymorphic identities. """ cls_name = unicode(cls.__name__) args = {'polymorphic_identity': cls_name} if cls_name == u'Modifier': args['polymorphic_on'] = cls._type return args def __init__(self, request, user, note, value): self.user = user self.note = ensure_unicode(note) self.value = value self.request = request def __repr__(self): return ("{x.__class__.__name__}({x.request}, {x.user}," "{x}, {x.voided})".format(x=self, value=self))
[docs] def void(self, user): """Mark this modifier as void. :param user: The user voiding this modifier :type user: :py:class:`~.User` """ if self.request.status != ActionType.evaluating: raise ModifierError("Modifiers can only be voided when the request" " is in the evaluating state.") if not user.has_permission(PermissionType.review, self.request.division): raise ModifierError("You must be a reviewer to be able to void " "modifiers.") self.voided_user = user self.voided_timestamp = dt.datetime.utcnow()
@unistr class AbsoluteModifier(Modifier): """Subclass of :py:class:`Modifier` for representing absolute modifications. Absolute modifications are those that are not dependent on the value of :py:attr:`Request.base_payout`. """ id = db.Column(db.Integer, db.ForeignKey('modifier.id'), primary_key=True) #: How much ISK to add or remove from the payout value = db.Column(PrettyNumeric(precision=15, scale=2), nullable=False, default=0.0) def __unicode__(self): return u'{}M ISK {}'.format(self.value / 1000000, u'bonus' if self.value >= 0 else u'penalty') @unistr class RelativeModifier(Modifier): """Subclass of :py:class:`Modifier` for representing relative modifiers. Relative modifiers depend on the value of :py:attr:`Modifier.base_payout` to calculate their effect. """ id = db.Column(db.Integer, db.ForeignKey('modifier.id'), primary_key=True) #: What percentage of the payout to add or remove value = db.Column(db.Float, nullable=False, default=0.0) def __unicode__(self): return u'{}% {}'.format(self.value * 100, u'bonus' if self.value >= 0 else u'penalty')
[docs]class Request(db.Model, AutoID, Timestamped, AutoName): """Requests represent SRP requests.""" #: The ID of the :py:class:`~.User` who submitted this request. submitter_id = db.Column(db.Integer, db.ForeignKey('user.id')) #: The :py:class:`~.User` who submitted this request. submitter = db.relationship('User', back_populates='requests') #: The ID of the :py:class`~.Division` this request was submitted to. division_id = db.Column(db.Integer, db.ForeignKey('division.id'), nullable=False) #: The :py:class:`~.Division` this request was submitted to. division = db.relationship('Division', back_populates='requests') #: A list of :py:class:`Action`\s that have been applied to this request, #: sorted in the order they were applied. actions = db.relationship('Action', back_populates='request', order_by='desc(Action.timestamp)') #: A list of all :py:class:`Modifier`\s that have been applied to this #: request, regardless of wether they have been voided or not. They're #: sorted in the order they were added. modifiers = db.relationship('Modifier', back_populates='request', order_by='desc(Modifier.timestamp)', lazy='dynamic') #: The URL of the source killmail. killmail_url = db.Column(db.String(512, convert_unicode=True), nullable=False) #: The ID of the :py:class:`~.Pilot` for the killmail. pilot_id = db.Column(db.Integer, db.ForeignKey('pilot.id'), nullable=False) #: The :py:class:`~.Pilot` for the killmail this request is for. pilot = db.relationship('Pilot', back_populates='requests') #: The corporation of the :py:attr:`pilot` at the time of the killmail. corporation = db.Column(db.String(150, convert_unicode=True), nullable=False, index=True) #: The alliance of the :py:attr:`pilot` at the time of the killmail. alliance = db.Column(db.String(150, convert_unicode=True), nullable=True, index=True) #: The type of ship that was destroyed. ship_type = db.Column(db.String(75, convert_unicode=True), nullable=False, index=True) # TODO: include timezones #: The date and time of when the ship was destroyed. kill_timestamp = db.Column(DateTime, nullable=False, index=True) #: The base payout for this request in millions of ISK. #: :py:attr:`modifiers` apply to this value. base_payout = db.Column(PrettyNumeric(precision=15, scale=2), default=0.0) #: Supporting information for the request. details = db.deferred(db.Column(db.Text(convert_unicode=True))) #: The current status of this request status = db.Column(ActionType.db_type(), nullable=False, default=ActionType.evaluating) #: The solar system this loss occured in. system = db.Column(db.String(25, convert_unicode=True), nullable=False, index=True) #: The constellation this loss occured in. constellation = db.Column(db.String(25, convert_unicode=True), nullable=False, index=True) #: The region this loss occured in. region = db.Column(db.String(25, convert_unicode=True), nullable=False, index=True) @property
[docs] def payout(self): """The resulting payout taking all active :py:attr:`modifiers` into account. The return value is an internal class that will return different representations depending on the type it is being coerced to. :py:class:`Strings <str>` will be formatted accroding to the current locale with thousands separators, :py:func:`float`\s will be in millions of ISK, and :py:func:`ints`\s will be the total ISK value (equivalent to the string representation). """ # Evaluation method for payout: # almost_payout = (sum(absolute_modifiers) + base_payout) # payout = almost_payout + (sum(percentage_modifiers) * almost_payout) abs_mods = db.session.query(db.func.sum(AbsoluteModifier.value))\ .join(Request)\ .filter(Modifier.request_id==self.id)\ .filter(~Modifier.voided) rel_mods = db.session.query(db.func.sum(RelativeModifier.value))\ .join(Request)\ .filter(Modifier.request_id==self.id)\ .filter(~Modifier.voided) absolute = abs_mods.one()[0] if absolute is None: absolute = Decimal(0) relative = rel_mods.one()[0] if relative is None: relative = Decimal(0) else: relative = Decimal.from_float(relative) payout = self.base_payout + absolute payout = payout + (payout * relative) return PrettyDecimal(payout)
@hybrid_property def finalized(self): """If this request is in a finalized status (``'paid'`` or ``'rejected'``). """ return self.status in ActionType.finalized @finalized.expression def finalized(cls): return db.or_(cls.status == ActionType.paid, cls.status == ActionType.rejected)
[docs] def __init__(self, submitter, details, division, killmail, **kwargs): """Create a :py:class:`Request`. :param submitter: The user submitting this request :type submitter: :py:class:`~.User` :param str details: Supporting details for this request :param division: The division this request is being submitted to :type division: :py:class:`~.Division` :param killmail: The killmail this request pertains to :type killmail: :py:class:`~.Killmail` """ self.division = division self.details = details self.submitter = submitter # Pull basically everything else from the killmail object # The base Killmail object has an iterator defined that returns tuples # of Request attributes and values for those attributes for attr, value in killmail: setattr(self, attr, value) # Set default values before a flush if self.base_payout is None and 'base_payout' not in kwargs: self.base_payout = Decimal(0) super(Request, self).__init__(**kwargs)
@db.validates('base_payout')
[docs] def validate_payout(self, attr, value): """Ensures that base_payout is positive. The value is clamped to 0.""" if self.status == ActionType.evaluating or self.status is None: if value is None or value < 0: return Decimal('0') else: return Decimal(value) else: raise ModifierError(u"The request must be in the evaluating state " u"to change the base payout.")
state_rules = { ActionType.evaluating: { ActionType.incomplete: (PermissionType.review,), ActionType.rejected: (PermissionType.review,), ActionType.approved: (PermissionType.review,), ActionType.evaluating: (PermissionType.review, PermissionType.submit), }, ActionType.incomplete: { ActionType.rejected: (PermissionType.review,), ActionType.evaluating: (PermissionType.review, PermissionType.submit), }, ActionType.rejected: { ActionType.evaluating: (PermissionType.review,), }, ActionType.approved: { ActionType.evaluating: (PermissionType.review,), ActionType.paid: (PermissionType.pay,), }, ActionType.paid: { ActionType.approved: (PermissionType.pay,), ActionType.evaluating: (PermissionType.pay,), }, }
[docs] def valid_actions(self, user): """Get valid actions (besides comment) the given user can perform.""" possible_actions = self.state_rules[self.status] def action_filter(action): return user.has_permission(possible_actions[action], self.division) return filter(action_filter, possible_actions)
@db.validates('status')
[docs] def validate_status(self, attr, new_status): """Enforces that status changes follow the status state diagram below. When an invalid change is attempted, :py:class:`ActionError` is raised. .. digraph:: request_workflow rankdir="LR"; sub [label="submitted", shape=plaintext]; node [style="dashed, filled"]; eval [label="evaluating", fillcolor="#fcf8e3"]; rej [label="rejected", style="solid, filled", fillcolor="#f2dede"]; app [label="approved", fillcolor="#d9edf7"]; inc [label="incomplete", fillcolor="#f2dede"]; paid [label="paid", style="solid, filled", fillcolor="#dff0d8"]; sub -> eval; eval -> rej [label="R"]; eval -> app [label="R"]; eval -> inc [label="R"]; rej -> eval [label="R"]; inc -> eval [label="R, S"]; inc -> rej [label="R"]; app -> paid [label="P"]; app -> eval [label="R"]; paid -> eval [label="P"]; paid -> app [label="P"]; R means a reviewer can make that change, S means the submitter can make that change, and P means payers can make that change. Solid borders are terminal states. """ def check_status(*valid_states): if new_status not in valid_states: raise ActionError(u"{} is not a valid status to change " u"to from {} (valid options: {})".format(new_status, self.status, valid_states)) if new_status == ActionType.comment: raise ValueError(u"ActionType.comment is not a valid status") # Initial status if self.status is None: return new_status rules = self.state_rules[self.status] if new_status not in rules: raise ActionError(u"{} is not a valid status to change to from {} " u"(valid options: {})".format(new_status, self.status, list(six.iterkeys(rules)))) return new_status
@db.validates('actions')
[docs] def update_status_from_action(self, attr, action): """Updates :py:attr:`status` whenever a new :py:class:`~.Action` is added and verifies permissions. """ if action.type_ is None: # Action.type_ are not nullable, so rely on the fact that it will # be set later to let it slide now. return action elif action.type_ != ActionType.comment: rules = self.state_rules[self.status] self.status = action.type_ permissions = rules[action.type_] if not action.user.has_permission(permissions, self.division): raise ActionError(u"Insufficient permissions to perform that " u"action.") elif action.type_ == ActionType.comment: if action.user != self.submitter \ and not action.user.has_permission(PermissionType.elevated, self.division): raise ActionError(u"You must either own or have special" u"privileges to comment on this request.") return action
def __repr__(self): return "{x.__class__.__name__}({x.submitter}, {x.division}, {x.id})".\ format(x=self) @db.validates('modifiers') def validate_add_modifier(self, attr, modifier): if self.status != ActionType.evaluating: raise ModifierError(u"Modifiers can only be added when the request" u" is in an evaluating state.") if not modifier.user.has_permission(PermissionType.review, self.division): raise ModifierError(u"Only reviewers can add modifiers.") return modifier @property
[docs] def transformed(self): """Get a special HTML representation of an attribute. Divisions can have a transformer defined on a for attributes that output a URL associated with that attribute. This property provides easy access to the output of any transformed attributes on this request. """ class RequestTransformer(object): def __getattr__(self, attr): raw_value = getattr(self._request, attr) if attr in self._request.division.transformers: transformer = self._request.division.transformers[attr] return Markup(u'<a href="{link}">{value}</a>').format( link=transformer(raw_value), value=str(raw_value)) else: return raw_value def __init__(self, request): self._request = request return RequestTransformer(self)