class UserState:
    """
    An aggregation of all the stuff user has gotten working on projects so far.
    """
    __slots__ = [
        'user',
        'level',
        'points',
        'actual_coins',
        'potential_coins',
        'achievements',
        'last_changed'
    ]

    def __init__(self,
                 user: User,
                 level: int,
                 points: Decimal,
                 actual_coins: Decimal,
                 potential_coins: Decimal,
                 achievements: list,
                 last_changed: datetime) -> None:
        """
        :type user: User
        :type level: int
        :type points: Decimal
        :type actual_coins: Decimal
        :type potential_coins: Decimal
        :type achievements: list[vulyk.blueprints.gamification.core.rules.Rule]
        :type last_changed: datetime
        """
        self.user = user
        self.level = level
        self.points = points
        self.actual_coins = actual_coins
        self.potential_coins = potential_coins
        self.achievements = {a.id: a for a in achievements}
        self.last_changed = last_changed

        self._validate()

    def _validate(self):
        """
        Keep the internal structure valid.

        :raises: InvalidUserStateException
        """
        try:
            assert self.user is not None, 'User must be present.'
            assert self.level >= 0, 'Level value must be zero or greater.'
            assert self.points >= Decimal(0), \
                'Points value must be zero or greater.'
            assert self.actual_coins >= Decimal(0), \
                'Actual coins value must be zero or greater.'
            assert self.potential_coins >= Decimal(0), \
                'Potential coins value must be zero or greater.'
            assert isinstance(self.achievements, dict), \
                'Achievements value must be a dict'
            assert isinstance(self.last_changed, datetime), \
                'Last changed value must be a datetime'
        except AssertionError as e:
            raise InvalidUserStateException(e)

    def to_dict(self) -> dict:
        """
        Could be used as a source for JSON or any other representation format

        :return: Dict-ized object view
        :rtype: dict
        """
        return {
            'user': self.user.username,
            'level': self.level,
            'points': self.points,
            'actual_coins': self.actual_coins,
            'potential_coins': self.potential_coins,
            'achievements': [r.to_dict() for r in self.achievements.values()],
            'last_changed': self.last_changed.strftime("%d.%m.%Y %H:%M:%S")
        }

    def __eq__(self, o: object) -> bool:
        if isinstance(o, UserState):
            return o.user.id == self.user.id \
                   and o.level == self.level \
                   and o.points == self.points \
                   and o.actual_coins == self.actual_coins \
                   and o.potential_coins == self.potential_coins \
                   and (set(o.achievements.keys()) ==
                        set(self.achievements.keys())) \
                   and o.last_changed == self.last_changed
        else:
            return False

    def __ne__(self, o: object) -> bool:
        return not self == o

    def __str__(self) -> str:
        return 'UserState({user}, {level}, {points}, {act_coins}, ' \
               '{pot_coins}, {badges}, {changed})' \
            .format(user=self.user.id,
                    level=self.level,
                    points=self.points,
                    act_coins=self.actual_coins,
                    pot_coins=self.potential_coins,
                    badges=self.achievements.keys(),
                    changed=self.last_changed)

    def __repr__(self) -> str:
        return str(self) class EventModel(Document):
    """
    Database-specific gamification system event representation
    """
    timestamp = ComplexDateTimeField(required=True)
    user = ReferenceField(
        document_type=User, db_field='user', required=True)
    answer = ReferenceField(
        document_type=AbstractAnswer, db_field='answer', required=False)
    # points must only be added
    points_given = DecimalField(min_value=0, required=True, db_field='points')
    # coins can be both given and withdrawn
    coins = DecimalField(required=True)
    achievements = ListField(
        field=ReferenceField(document_type=RuleModel, required=False))
    # if user donates earned coins to a fund, specify the fund
    acceptor_fund = ReferenceField(
        document_type=FundModel, required=False, db_field='acceptorFund')
    level_given = IntField(min_value=1, required=False, db_field='level')
    # has user seen an update or not
    viewed = BooleanField(default=False)

    meta = {
        'collection': 'gamification.events',
        'allow_inheritance': True,
        'indexes': [
            'user',
            {
                'fields': ['answer'],
                'unique': True,
                'sparse': True
            },
            'acceptor_fund',
            'timestamp'
        ]
    }

    def to_event(self) -> Event:
        """
        DB-specific model to Event converter.

        :return: New Event instance
        :rtype: Event
        """
        return Event.build(
            timestamp=self.timestamp,
            user=self.user,
            answer=self.answer,
            points_given=self.points_given,
            coins=self.coins,
            achievements=[a.to_rule() for a in self.achievements if hasattr(a, "to_rule")],
            acceptor_fund=None
            if self.acceptor_fund is None
            else self.acceptor_fund.to_fund(),
            level_given=self.level_given,
            viewed=self.viewed
        )

    @classmethod
    def from_event(cls, event: Event):
        """
        Event to DB-specific model converter.

        :param event: Source event instance
        :type event: Event

        :return: New full-bodied model instance
        :rtype: EventModel
        """
        return cls(
            timestamp=event.timestamp,
            user=event.user,
            answer=event.answer,
            points_given=event.points_given,
            coins=event.coins,
            achievements=RuleModel.objects(
                id__in=[r.id for r in event.achievements]),
            acceptor_fund=None
            if event.acceptor_fund is None
            else FundModel.objects.get(id=event.acceptor_fund.id),
            level_given=event.level_given,
            viewed=event.viewed
        )

    @classmethod
    def get_unread_events(cls, user: User) -> Generator[Event, None, None]:
        """
        Returns aggregated and sorted list of generator (achievements & level-ups)
        user'd been given but hasn't checked yet.

        :param user: The user to extract events for
        :type user: User

        :return: A generator of events in ascending chronological order.
        :rtype: Generator[Event, None, None]
        """

        for ev in cls.objects(user=user, viewed=False):
            yield ev.to_event()

    @classmethod
    def mark_events_as_read(cls, user: User) -> None:
        """
        Mark all user events as viewed

        :param user: The user to mark unseed events as viewed
        :type user: User

        :return: Nothing. None. Empty. Long Gone
        :rtype: None
        """
        cls.objects(user=user, viewed=False).update(set__viewed=True)

    @classmethod
    def get_all_events(cls, user: User) -> Iterator:
        """
        Returns aggregated and sorted generator of events
        (achievements & level-ups) user'd been given.

        :param user: The user to extract events for
        :type user: User

        :return: A generator of events in ascending chronological order.
        :rtype: Generator[Event, None, None]
        """

        for ev in cls.objects(user=user):
            yield ev.to_event()

    @classmethod
    def count_of_tasks_done_by_user(cls, user: User) -> int:
        """
        Number of tasks finished by current user

        :param user: User instance
        :type user: User

        :return: Count of tasks done
        :rtype: int
        """
        return cls.objects(user=user, answer__exists=True).count()

    @classmethod
    def amount_of_money_donated(cls, user: Optional[User]) -> float:
        """
        Amount of money donated by current user
        or total donations if None passed.

        :param user: User instance
        :type user: Optional[User]

        :return: Amount of money
        :rtype: float
        """

        query = Q(acceptor_fund__ne=None)

        if user is not None:
            query &= Q(user=user)

        return -cls.objects(query).sum('coins')

    @classmethod
    def amount_of_money_earned(cls, user: Optional[User]) -> float:
        """
        Amount of money earned by current user
        or total amount earned if None is passed.

        :param user: User instance
        :type user: Optional[User]

        :return: Amount of money
        :rtype: float
        """

        query = Q(coins__gt=0)

        if user is not None:
            query &= Q(user=user)

        return cls.objects(query).sum('coins')

    @classmethod
    def batches_user_worked_on(
        cls,
        user: User
    ) -> Generator[Batch, None, None]:
        """
        Returns an iterable of deduplicated batches user has worked on before.

        :param user: User instance
        :type user: User

        :return: Iterator over batches
        :rtype: Generator[Batch, None, None]
        """
        seen = set()

        for ev in cls.objects(user=user, answer__exists=True).only('answer'):
            batch = ev.answer.task.batch

            if batch.id not in seen:
                seen.add(batch.id)

                yield batch

    def __str__(self) -> str:
        return 'EventModel({model})'.format(model=str(self.to_event()))

    def __repr__(self) -> str:
        return str(self) class FundModel(Document):
    """
    Database-specific foundation representation
    """
    id = StringField(required=True, max_length=50, primary_key=True)
    name = StringField(required=True)
    description = StringField(required=True)
    site = StringField(required=False)
    email = EmailField(required=False, allow_utf8_user=True)
    logo = ImageField(unique=True, thumbnail_size=(141, 106, True))
    donatable = BooleanField(required=True)

    meta = {
        'collection': 'gamification.funds',
        'allow_inheritance': True,
        'indexes': [
            'name',
            'donatable'
        ]
    }

    def to_fund(self) -> Fund:
        """
        DB-specific model to Fund converter.

        :return: New Fund instance
        :rtype: Fund
        """
        return Fund(
            fund_id=self.id,
            name=self.name,
            description=self.description,
            site=self.site,
            email=self.email,
            logo=self.logo.get(),
            donatable=self.donatable)

    @classmethod
    def from_fund(cls, fund: Fund):
        """
        Fund to DB-specific model converter.

        :param fund: Current Fund instance
        :type fund: Fund

        :return: New FundModel instance
        :rtype: FundModel
        """
        result = cls(
            id=fund.id,
            name=fund.name,
            description=fund.description,
            site=fund.site,
            email=fund.email,
            donatable=fund.donatable)
        result.logo.put(fund.logo)

        return result

    @classmethod
    def find_by_id(cls, fund_id: str) -> Optional[Fund]:
        """
        Convenience method that returns an optional fund by its ID.

        :param fund_id: Fund's ID
        :type fund_id: str

        :return: Found instance or none.
        :rtype: Optional[Fund]
        """
        result = None

        try:
            result = FundModel.objects.get(id=fund_id).to_fund()
        except cls.DoesNotExist:
            pass

        return result

    @classmethod
    def get_funds(
        cls,
        filter_by: FundFilterBy = FundFilterBy.NO_FILTER
    ) -> Iterator[Fund]:
        """
        Returns an enumeration of funds available using the filtering policy
        passed.

        :param filter_by: Filtering policy
        :type filter_by: FundFilterBy

        :return: Lazy enumeration of funds available.
        :rtype: Iterator[Fund]
        """
        criteria = Q()

        if filter_by != FundFilterBy.NO_FILTER:
            if filter_by == FundFilterBy.DONATABLE:
                criteria = Q(donatable=True)
            elif filter_by == FundFilterBy.NON_DONATABLE:
                criteria = Q(donatable=False)

        yield from map(lambda f: f.to_fund(), cls.objects(criteria))

    def __str__(self) -> str:
        return 'FundModel({model})'.format(model=str(self.to_fund()))

    def __repr__(self) -> str:
        return str(self) class UserStateModel(Document):
    user = ReferenceField(document_type=User, required=True, unique=True)
    level = IntField(min_value=0, default=0)
    # As for mongoengine 0.17.0 the min_value parameter governs not the field
    # value only, but every influx value as well.
    # E.g. the field has value of Decimal(100) and one wants to decrease by 1.
    # The change mutates from points__dec: Decimal(1)
    # to points__inc: Decimal(-1), and then the value Decimal(-1)
    # is validated against self.min_value = 0.
    # Even if the result is supposed to be 99, validation will fail as -1 < 0.
    points = DecimalField(default=0)
    actual_coins = DecimalField(default=0, db_field='actualCoins')
    potential_coins = DecimalField(default=0, db_field='potentialCoins')
    achievements = ListField(
        field=ReferenceField(document_type=RuleModel, required=False))
    last_changed = ComplexDateTimeField(
        db_field='lastChanged',
        default=datetime.utcnow)

    meta = {
        'collection': 'gamification.userState',
        'allow_inheritance': True,
        'indexes': [
            'user',
            'last_changed'
        ]
    }

    def to_state(self) -> UserState:
        """
        DB-specific model to UserState converter.

        It isn't supposed to dig out what's been buried once, yet this method
        is really useful for tests.

        :return: New UserState instance
        :rtype: UserState
        """
        return UserState(
            user=self.user,
            level=self.level,
            points=self.points,
            actual_coins=self.actual_coins,
            potential_coins=self.potential_coins,
            achievements=[r.to_rule()
                          for r in self.achievements
                          if hasattr(r, "to_rule")],
            last_changed=self.last_changed
        )

    @classmethod
    def from_state(
        cls,
        state: UserState
    ):
        """
        UserState to DB-specific model converter.

        :param state: Source user state
        :type state: UserState

        :return: New model instance
        :rtype: UserStateModel
        """
        return cls(
            user=state.user,
            level=state.level,
            points=state.points,
            actual_coins=state.actual_coins,
            potential_coins=state.potential_coins,
            achievements=RuleModel.objects(
                id__in=[r.id for r in state.achievements.values()]),
            last_changed=state.last_changed
        )

    @classmethod
    def get_or_create_by_user(
        cls,
        user: User
    ) -> UserState:
        """
        Returns an existing UserStateModel instance bound to the user, or a new
        one if didn't exist before.

        :param user: Current User model instance
        :type user: User

        :return: UserState instance
        :rtype: UserState
        """
        try:
            state_model = cls.objects.get(user=user)
        except cls.DoesNotExist:
            state_model = cls.objects.create(user=user)

        return state_model.to_state()

    @classmethod
    def update_state(
        cls,
        diff: UserState
    ) -> None:
        """
        Prepares and conducts an atomic update query from passed diff.

        :param diff: State object contains values that are to be changed only.
        :type diff: UserState

        :rtype: None
        """
        update_dict = {'set__last_changed': diff.last_changed}

        if diff.level > 0:
            update_dict['set__level'] = diff.level
        if diff.points > 0:
            # DON'T PUT YOUR BLAME ON ME
            # https://github.com/MongoEngine/mongoengine/blob/master/mongoengine/fields.py#L415
            update_dict['inc__points'] = float(diff.points)
        if diff.actual_coins != 0:
            # ALL YOUR DECIMALS ARE BELONG TO US
            update_dict['inc__actual_coins'] = float(diff.actual_coins)
        if diff.potential_coins != 0:
            update_dict['inc__potential_coins'] = float(diff.potential_coins)
        if len(diff.achievements) > 0:
            update_dict['add_to_set__achievements'] = \
                cls.from_state(diff).achievements

        cls.objects.get(user=diff.user).update(**update_dict)

    @classmethod
    def get_top_users(
        cls,
        limit: int,
        sort_by: StateSortingKeys
    ) -> Iterator[UserState]:
        """
        Enumerates over top users basing on passed sorting criteria and
        limiting number. The yield sorting is descending.

        :param limit: Top limit
        :type limit: int
        :param sort_by: Criteria enumeration item
        :type sort_by: StateSortingKeys

        :return: An iterator
        :rtype: Iterator[UserState]
        """
        yield from map(
            lambda state_model: state_model.to_state(),
            cls.objects().order_by('-%s' % sort_by.name.lower()).limit(limit)
        )

    def __str__(self) -> str:
        return 'UserStateModel({model})'.format(model=str(self.to_state()))

    def __repr__(self) -> str:
        return str(self)

    @classmethod
    def withdraw(
        cls,
        user: User,
        amount: Decimal
    ) -> bool:
        """
        Reflects money withdrawal from current account.

        :param user: User to perform the action on.
        :type user: User
        :param amount: Money amount (ONLY positive)
        :type amount: Decimal

        :raise:
            - RuntimeError – if amount is negative

        :return: True if needed amount was successfully withdrawn
        :rtype: bool
        """
        if amount <= 0:
            raise RuntimeError('Donation amount should be positive')

        update_dict = {'dec__actual_coins': amount}

        return cls.objects(user=user, actual_coins__gte=amount) \
            .update(**update_dict) == 1

    @classmethod
    def transfer_coins_to_actual(
        cls,
        uid: ObjectId,
        amount: Decimal
    ) -> bool:
        """
        :param uid: Current user ID
        :type uid: ObjectId
        :param amount: Money amount
        :type amount: Decimal

        :return: True if success
        :rtype: bool
        """
        result = cls \
            .objects(user=uid, potential_coins__gte=amount) \
            .update_one(inc__actual_coins=amount, dec__potential_coins=amount)

        return result == 1 class DonationResult(Enum):
    """
    An enumeration to represent different results of donation process.
    """
    SUCCESS = 0
    STINGY = 1
    BEGGAR = 2
    LIAR = 3
    ERROR = 666 class DonationsService:
    """
    Class hides mildly complex donation logic from cruel outer world.
    """

    def __init__(self, user: User, fund_id: str, amount: Decimal) -> None:
        """
        Constructor.

        :param user: Current user
        :type user: User
        """
        self._amount = amount
        self._user = user
        self._fund = FundModel.find_by_id(fund_id)

    def donate(self) -> DonationResult:
        """
        Perform a donation process:
         - check if there is enough active money to spare
         - check if fund exists
         - try to decrease amount of coins on current account
         - create and save an event

        :return: One of `DonationResult` enum values:
            SUCCESS - everything went okay;
            STINGY - you tried to donate nothing;
            BEGGAR - you have less money than tried to spare;
            LIAR - you passed non-existent fund;
            ERROR - sh*t happened :( .
        :rtype: DonationResult
        """
        if self._amount <= 0:
            return DonationResult.STINGY

        if self._fund is None:
            return DonationResult.LIAR

        try:
            if UserStateModel.withdraw(user=self._user, amount=self._amount):
                EventModel.from_event(
                    DonateEvent(
                        timestamp=datetime.now(),
                        user=self._user,
                        coins=-self._amount,
                        acceptor_fund=self._fund)
                ).save()

                return DonationResult.SUCCESS
            else:
                return DonationResult.BEGGAR
        except (Exception, IOError):
            return DonationResult.ERROR class Group(Document):
    """
    Class was introduced to serve the permissions purpose
    """
    id = StringField(max_length=100, primary_key=True)
    description = StringField(max_length=200)
    allowed_types = ListField(StringField(max_length=100))

    meta = {'collection': 'groups'}

    def __str__(self) -> str:
        return str(self.id)

    def __repr__(self) -> str:
        return 'Group ID: {id}. Allowed types: {types}'.format(
            id=self.id,
            types=self.allowed_types) class User(Document, UserMixin):
    """
    Main model for member entity.
    """
    username = StringField(max_length=200)
    password = StringField(max_length=200, default='')
    name = StringField(max_length=100)
    email = StringField()
    active = BooleanField(default=True)
    admin = BooleanField(default=False)
    groups = ListField(
        ReferenceField(Group, reverse_delete_rule=PULL, default=None))
    last_login = DateTimeField(default=datetime.datetime.now)
    processed = IntField(default=0)

    def is_active(self) -> bool:
        return self.active

    def is_admin(self) -> bool:
        return self.admin or False

    def is_eligible_for(self, task_type: str) -> bool:
        """
        Check that user is authorized to work with this tasks type

        :param task_type: Tasks type name
        :type task_type: str

        :return: True if user is eligible
        :rtype: bool

        :raises: AssertionError - if no `task_type` specified
        """
        assert task_type, 'Empty parameter `task_type` passed'

        return self.admin \
               or task_type in chain(*(g.allowed_types for g in self.groups))

    def get_stats(self, task_type) -> Dict[str, int]:
        """
        Returns member's stats containing the number of tasks finished and
        the position in the global rank.

        :param task_type: Task type instance.
        :type task_type: vulyk.models.task_types.AbstractTaskType

        :return: Dictionary that contains total finished tasks count and the
                 position in the global rank.
        :rtype: Dict[str, int]
        """
        leaders = task_type.get_leaders()
        i = 0
        prev_val = -1
        total = 0

        for user, freq in leaders:
            if freq != prev_val:
                i += 1
                prev_val = freq

            if user == self.id:
                total = freq
                break

        return {
            'total': total,
            'position': i
        }

    def as_dict(self) -> Dict[str, str]:
        """
        Converts the model-instance into a safe dict that will include some
        basic info about member.

        :return: Reduced set of information about member.
        :rtype: Dict[str, str]
        """
        return {
            'username': self.username,
            'email': self.email
        }

    @classmethod
    def pre_save(cls, sender: Type, document: Document, **kwargs: Dict) -> Document:
        """
        A signal handler which will put a new member into a default group if
        any hasn't been assigned yet.

        :param sender: Type of signal emitter.
        :type sender: Type
        :param document: New instance of User model.
        :type document: User
        :param kwargs: Additional parameters
        :type kwargs: Dict

        :return: Modified User instance.
        :rtype: User
        """
        if all(map(lambda x: x.id != 'default', document.groups)):
            try:
                document.groups = [Group.objects.get(id='default')]
            except Group.DoesNotExist:
                raise Group.DoesNotExist('Please run \'manage.py init ...\'')

        return document

    @classmethod
    def get_by_id(cls, user_id: str) -> Optional[Document]:
        """
        :param user_id: Needed user ID
        :type user_id: str

        :return: The user
        :rtype: Optional[User]
        """
        try:
            return cls.objects.get(id=user_id)
        except (cls.DoesNotExist, ValidationError):
            return None

    def __str__(self) -> str:
        return self.username class FakeType(AbstractGamifiedTaskType):
    task_model = FakeModel
    answer_model = FakeAnswer
    type_name = 'FakeGamifiedTaskType'
    template = 'tmpl.html'

    _name = 'Fake name'
    _description = 'Fake description' class FixtureFund:
    FUND_ID = 'newfund'
    FUND_NAME = 'New fund'
    FUND_DESCRIPTION = 'description'
    FUND_SITE = 'site.com'
    FUND_EMAIL = 'email@email.ek'
    FUND_DONATABLE = True
    LOAD = b'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAbElEQVR' \
           b'4nO3Q0QmAMBAFwWitKSvFWsOKECIz/8c7dgwAAAAAAAAAAAAAADjHtW' \
           b'15zve3a333R3BvWT2UWIFYgViBWIFYgViBWIFYgViBWIFYgViBWIFYg' \
           b'ViBWIFYgVgAAAAAAAAAAAAAAPBTD1i3AiiQSFCiAAAAAElFTkSuQmCC'
    LOGO_BYTES = base64.b64decode(LOAD)

    @classmethod
    def get_fund(cls,
                 fund_id: str = FUND_ID,
                 name: str = FUND_NAME,
                 donatable: bool = FUND_DONATABLE) -> Fund:
        """
        :param fund_id: Fund ID
        :type fund_id: str
        :type name: Fund name
        :param donatable: Is donatable
        :type donatable: bool

        :return: Fully fledged fund instance
        :rtype: Fund
        """
        with TemporaryFile('w+b', suffix='.jpg') as f:
            f.write(cls.LOGO_BYTES)

            fund = Fund(
                fund_id=fund_id,
                name=name,
                description=cls.FUND_DESCRIPTION,
                site=cls.FUND_SITE,
                email=cls.FUND_EMAIL,
                logo=f,
                donatable=donatable)
            FundModel.from_fund(fund).save()

            return fund class BaseTest(unittest.TestCase):
    DB_NAME = 'vulyk_test'
    MONGO_URI = 'mongodb://localhost:27017/'
    # override to run against test DB during tests
    settings.MONGODB_SETTINGS['DB'] = DB_NAME
    # allows us to run selected tests w/o need to launch the whole bunch
    register_connection('default', name=DB_NAME, host=MONGO_URI) class FakeType(AbstractTaskType):
    task_model = FakeModel
    answer_model = FakeAnswer
    type_name = 'FakeTaskType'
    template = 'tmpl.html'

    _name = 'Fake name'
    _description = 'Fake description'
from datetime import datetime
from decimal import Decimal
from unittest.mock import patch
from mongoengine import OperationError
from vulyk.blueprints.gamification.core.state import UserState
from vulyk.blueprints.gamification.models.events import EventModel
from vulyk.blueprints.gamification.models.foundations import FundModel
from vulyk.blueprints.gamification.models.state import UserStateModel
from vulyk.blueprints.gamification.services import (
    DonationResult, DonationsService)
from vulyk.models.user import Group, User
from .fixtures import FakeType, FixtureFund
from ..base import BaseTest
from ..fixtures import FakeType as BaseFakeType
import unittest

        EventModel.objects.delete()
        FundModel.objects.delete()
        FundModel._get_db()['images.files'].drop()
        FundModel._get_db()['images.chunks'].drop()
        UserStateModel.objects.delete()

    def test_donation_service_success(self):
        fund = FixtureFund.get_fund()
        amount = Decimal(100)
        state = UserStateModel.from_state(
            UserState(
                user=self.USER,
                level=0,
                points=Decimal(3000.67),
                actual_coins=Decimal(200),
                potential_coins=Decimal(500),
                achievements=[],
                last_changed=datetime.now()
            )).save()
        result = DonationsService(self.USER, fund.id, amount).donate()
        state.reload()

        self.assertEqual(result, DonationResult.SUCCESS,
                         'Donation result should be SUCCESS')
        self.assertEqual(state.actual_coins, Decimal(100),
                         'Actual coins value must be 100')

    def test_donation_service_success_event(self):
        fund = FixtureFund.get_fund()
        amount = Decimal(100)
        UserStateModel.from_state(
            UserState(
                user=self.USER,
                level=0,
                points=Decimal(3000.67),
                actual_coins=Decimal(200),
                potential_coins=Decimal(500),
                achievements=[],
                last_changed=datetime.now()
            )).save()
        DonationsService(self.USER, fund.id, amount).donate()
        event = EventModel.objects.get(user=self.USER)

        self.assertEqual(event.coins, Decimal(-100))
        self.assertEqual(event.acceptor_fund.id, fund.id)

    def test_donation_service_two_success_events(self):
        fund = FixtureFund.get_fund()
        amount = Decimal(100)
        UserStateModel.from_state(
            UserState(
                user=self.USER,
                level=0,
                points=Decimal(3000.67),
                actual_coins=Decimal(500),
                potential_coins=Decimal(500),
                achievements=[],
                last_changed=datetime.now()
            )).save()
        result = DonationsService(self.USER, fund.id, amount).donate()
        event = EventModel.objects.get(user=self.USER)

        self.assertEqual(event.coins, Decimal(-100))
        self.assertEqual(result, DonationResult.SUCCESS)
        self.assertEqual(event.acceptor_fund.id, fund.id)

        amount = Decimal(50)
        result = DonationsService(self.USER, fund.id, amount).donate()
        event = EventModel.objects.filter(user=self.USER).order_by("-timestamp")[0]

        self.assertEqual(event.coins, Decimal(-50))
        self.assertEqual(result, DonationResult.SUCCESS)
        self.assertEqual(event.acceptor_fund.id, fund.id)

    def test_donation_service_beggar(self):
        fund = FixtureFund.get_fund()
        amount = Decimal(200)
        state = UserStateModel.from_state(
            UserState(
                user=self.USER,
                level=0,
                points=Decimal(3000.67),
                actual_coins=Decimal(100),
                potential_coins=Decimal(500),
                achievements=[],
                last_changed=datetime.now()
            )).save()
        result = DonationsService(self.USER, fund.id, amount).donate()
        state.reload()

        self.assertEqual(result, DonationResult.BEGGAR,
                         'Donation result should be BEGGAR')
        self.assertEqual(state.actual_coins, Decimal(100),
                         'Actual coins value must be 100')

    def test_donation_service_stingy_zero(self):
        fund = FixtureFund.get_fund()
        amount = Decimal(0)
        state = UserStateModel.from_state(
            UserState(
                user=self.USER,
                level=0,
                points=Decimal(3000.67),
                actual_coins=Decimal(100),
                potential_coins=Decimal(500),
                achievements=[],
                last_changed=datetime.now()
            )).save()
        result = DonationsService(self.USER, fund.id, amount).donate()
        state.reload()

        self.assertEqual(result, DonationResult.STINGY,
                         'Donation result for zero amount should be STINGY')
        self.assertEqual(state.actual_coins, Decimal(100),
                         'Actual coins value must be 100')

    def test_donation_service_stingy_negative(self):
        fund = FixtureFund.get_fund()
        amount = Decimal(-10)

        state = UserStateModel.from_state(