TASKS_TYPES = bootstrap.init_plugins(app) def track_events(sender: object, answer: AbstractAnswer) -> None:
def get_actual_rules(
    state: UserState,
    task_type_name: str,
    now: datetime
) -> Iterator[Rule]:
def materialize_coins(sender: Batch) -> None: class Rule:
    """
    Base class for all rule containers.
    Those rules may be interpreted like the following:

    - you closed n tasks
    - you closed n tasks in m days
    - you closed n tasks in weekends
    - you've been working for m weekends
    - you've been working for m days in a row
    - you've been working for m weekends in a row

    Also additional bonuses to be given after user gets the achievement are
    included as well as achievement and its badge is.
    """
    __slots__ = [
        'badge',
        'name',
        'description',
        'bonus',
        '_tasks_number',
        '_days_number',
        '_is_weekend',
        '_is_adjacent',
        '_hash'
    ]

    def __init__(self,
                 rule_id: str,
                 badge: str,
                 name: str,
                 description: str,
                 bonus: int,
                 tasks_number: int,
                 days_number: int,
                 is_weekend: bool,
                 is_adjacent: bool) -> None:
        """
        :param rule_id: Unique rule identifier.
        :type rule_id: str
        :param badge: Badge image (either base64 or URL)
        :type badge: str
        :param name: Achievement name
        :type name: str
        :param description: Achievement textual description
        :type description: str
        :param bonus: How much additional points we give for the achievement
        :type bonus: int
        :param tasks_number: Number of tasks needed to achieve
        :type tasks_number: int
        :param days_number: Number of days needed to achieve
        :type days_number: int
        :param is_weekend: Should this achievement be tied up with weekends?
        :type is_weekend: bool
        :param is_adjacent: Must it be given for work in adjacent days?
        :type is_adjacent: bool
        """
        self.badge = badge
        self.name = name
        self.description = description
        self.bonus = bonus

        self._tasks_number = tasks_number
        self._days_number = days_number
        self._is_weekend = is_weekend
        self._is_adjacent = is_adjacent

        self._hash = rule_id

        self._validate()

    def _validate(self) -> None:
        """
        Verifies that the rule complies to internal invariants. Otherwise –
        the exception must be thrown.

        :raises: RuleValidationException
        """
        # no achievements for n tasks closed on m adjacent days/weekends
        # might be extended later
        if self._is_adjacent:
            if self._tasks_number > 0:
                raise RuleValidationException(
                    'Counting number of tasks closed in adjacent days/weekends'
                    ' is not supported.')
            elif self._days_number == 0:
                raise RuleValidationException(
                    'Can not set "adjacent" flag with zero days')

        if self._tasks_number == 0 and self._days_number == 0:
            raise RuleValidationException('There must be at least one '
                                          'numeric bound')

    @property
    def id(self) -> str:
        return self._hash

    @property
    def tasks_number(self) -> int:
        return self._tasks_number

    @property
    def days_number(self) -> int:
        return self._days_number

    @property
    def is_weekend(self) -> bool:
        return self._is_weekend

    @property
    def is_adjacent(self) -> bool:
        return self._is_adjacent

    @property
    def limit(self) -> int:
        """
        The vital characteristic of the rule: the limit member should surpass
        to get the achievement.
        At current stage the property relies upon two values: tasks done by
        user and days he spent working on these tasks. The priority is
        following: if tasks number is specified, it always supersedes days.

        E.g.: if rule has `tasks_number=20` and `days_number=7` – limit is 20.
        We take the number of tasks done in 7 days, and compare it to 20.
        Otherwise, if only `days_number=7` is specified, limit is 7. We group
        all tasks were done not earlier than 7 days ago, group them by day and
        check if 7 items is returned from aggregation pipeline.

        :return: The value to be compared to aggregation results.
        :rtype: int
        """
        return self._tasks_number \
            if (self._tasks_number or 0) > 0 \
            else (self._days_number or 0)

    def to_dict(self) -> Dict[str, Union[str, int, bool]]:
        """
        Could be used as a source for JSON or any other representation format

        :return: Dict-ized object view
        :rtype: Dict[str, Union[str, int, bool]]
        """
        return {
            'id': self.id,
            'badge': self.badge,
            'name': self.name,
            'description': self.description,
            'bonus': self.bonus,
            'tasks_number': self.tasks_number,
            'days_number': self.days_number,
            'is_weekend': self.is_weekend,
            'is_adjacent': self.is_adjacent
        }

    def __eq__(self, o: object) -> bool:
        if isinstance(o, Rule):
            return self._hash == o.id
        else:
            return False

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

    def __str__(self) -> str:
        return 'Rule({name}, {bonus}, {tasks}, {days}, {week}, {adj})'.format(
            name=self.name,
            bonus=self.bonus,
            tasks=self._tasks_number,
            days=self._days_number,
            week=self._is_weekend,
            adj=self._is_adjacent
        )

    def __repr__(self) -> str:
        return 'Rule({name}, {descr})'.format(
            name=self.name,
            descr=self.description[:50] + '...',
        ) 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 InvalidUserStateException(BaseException):
    """
    Generic container for all types of error may happen during
    user state construction.
    """
    pass 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 RuleModel(Document):
    """
    Database-specific rule representation
    """
    id = StringField(required=True, primary_key=True)
    task_type_name = StringField()
    badge = StringField(required=True)
    name = StringField(required=True, max_length=255, unique=True)
    description = StringField(required=True)
    bonus = IntField(min_value=0)
    tasks_number = IntField(min_value=0, db_field='tasksNumber')
    days_number = IntField(min_value=0, db_field='daysNumber')
    is_weekend = BooleanField(default=False, db_field='isWeekend')
    is_adjacent = BooleanField(default=False, db_field='isAdjacent')

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

    @classmethod
    def from_rule(cls, rule: Rule):
        """
        Rule to DB-specific model converter.

        :param rule: Current Rule instance
        :type rule: Rule

        :return: New RuleModel instance
        :rtype: RuleModel
        """
        task_type_name = rule.task_type_name \
            if isinstance(rule, ProjectRule) \
            else ''  # type: str

        return cls(
            id=rule.id,
            task_type_name=task_type_name,
            badge=rule.badge,
            name=rule.name,
            description=rule.description,
            bonus=rule.bonus,
            tasks_number=rule.tasks_number,
            days_number=rule.days_number,
            is_weekend=rule.is_weekend,
            is_adjacent=rule.is_adjacent
        )

    def to_rule(self) -> Rule:
        """
        DB-specific model to Rule converter.

        :return: New Rule instance
        :rtype: Rule
        """
        rule = Rule(
            rule_id=self.id,
            badge=self.badge,
            name=self.name,
            description=self.description,
            bonus=self.bonus,
            tasks_number=self.tasks_number,
            days_number=self.days_number,
            is_weekend=self.is_weekend,
            is_adjacent=self.is_adjacent
        )

        if self.task_type_name:
            return ProjectRule.from_rule(
                rule=rule,
                task_type_name=self.task_type_name
            )
        else:
            return rule

    @classmethod
    def get_actual_rules(
        cls,
        skip_ids: List[str],
        rule_filter: RuleFilter,
        is_weekend: bool
    ) -> Iterator[Rule]:
        """
        Prepare the list of rules to apply.
        Cutting off is possible by using different tiny yet smart heuristics.

        :param skip_ids: A list of rules to be skipped for any reason
        :type skip_ids: List[str]
        :param rule_filter: Prepared query container.
        :type rule_filter: RuleFilter
        :param is_weekend: If today is a working day, there is no reason to
            check for rules that are weekend-backed.
        :type is_weekend: bool

        :return: An array of rules to be checked and assigned.
        :rtype: Iterator[Rule]
        """
        base_q = rule_filter.to_query()

        if len(skip_ids) > 0:
            base_q &= Q(id__nin=skip_ids)

        if not is_weekend:
            base_q &= Q(is_weekend=False)

        yield from [rule_model.to_rule() for rule_model in cls.objects(base_q)]

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

    def __repr__(self) -> str:
        return 'RuleModel({model})'.format(model=repr(self.to_rule())) class ProjectAndFreeRules(RuleFilter):
    """
    Should return all rules related to a certain project along with
    project-agnostic ones.
    """

    def __init__(self, task_type_name: str) -> None:
        self._task_type_name = task_type_name

    def to_query(self) -> Q:
        """
        Prepares a filtering query.

        :return: Prepared query instance.
        :rtype: Q
        """
        return (Q(task_type_name=self._task_type_name)
                | Q(task_type_name='')
                | Q(task_type_name__exists=False))

    def __eq__(self, o: object) -> bool:
        if isinstance(o, ProjectAndFreeRules):
            return self._task_type_name == o._task_type_name
        else:
            return False 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 POINTS_PER_TASK_KEY = 'points_per_task' COINS_PER_TASK_KEY = 'coins_per_task' class WorkSession(Document):
    """
    Class which represents a timespan during which user was working on the task
    Also it stores links to every entity involved.
    """
    user = ReferenceField(User, reverse_delete_rule=CASCADE, required=True)
    task = ReferenceField(AbstractTask, reverse_delete_rule=CASCADE,
                          required=True)
    task_type = StringField(max_length=50, required=True, db_field='taskType')
    answer = ReferenceField(AbstractAnswer, reverse_delete_rule=CASCADE)

    start_time = DateTimeField(required=True)
    end_time = DateTimeField(required=False)
    activity = LongField()

    meta = {
        'allow_inheritance': True,
        'collection': 'work_sessions',
        'indexes': [
            ('user', 'task'),
            'task'
        ]
    }

    @classmethod
    def get_total_user_time_precise(cls, user_id: ObjectId) -> int:
        """
        Aggregated time spent doing tasks on all projects by certain user.
        As the source we use more precise value of activity field.

        :param user_id: User ID
        :type user_id: ObjectId

        :return: Total time (in seconds)
        :rtype: int
        """
        return sum(
            map(lambda session: session.activity,
                cls.objects(user=user_id)))

    @classmethod
    def get_total_user_time_approximate(cls, user_id: ObjectId) -> int:
        """
        Aggregated time spent doing tasks on all projects by certain user.
        As the source we use approximate values of start time and end time.
        Might be useful if no proper time accounting is done on frontend.

        :param user_id: User ID
        :type user_id: ObjectId

        :return: Total time (in seconds)
        :rtype: int
        """
        return sum(map(
            lambda session: (session.end_time - session.start_time).seconds,
            cls.objects(user=user_id))) class AbstractTask(Document):
    """
    This is AbstractTask model.
    You need to inherit it in your model
    """
    id = StringField(max_length=200, default='', primary_key=True)
    task_type = StringField(max_length=50, required=True, db_field='taskType')
    batch = ReferenceField(Batch, reverse_delete_rule=CASCADE)

    users_count = IntField(default=0, db_field='usersCount')
    users_processed = ListField(ReferenceField(User),
                                db_field='usersProcessed')
    users_skipped = ListField(ReferenceField(User), db_field='usersSkipped')

    closed = BooleanField(default=False)
    task_data = DictField(required=True)

    meta = {
        'collection': 'tasks',
        'allow_inheritance': True,
        'indexes': [
            'task_type',
            'batch'
        ]
    }

    def as_dict(self) -> Dict[str, Any]:
        """
        Converts the model-instance into a safe and lightweight dictionary.

        :rtype: Dict[str, Any]
        """
        return {
            'id': self.id,
            'closed': self.closed,
            'data': self.task_data
        }

    @classmethod
    def ids_in_batch(cls, batch: Batch) -> List[str]:
        """
        Collects IDs of all tasks that belong to certain batch.

        :param batch: Batch instance
        :type batch: Batch

        :return: List of IDs
        :rtype: List[str]
        """
        return cls.objects(batch=batch).distinct('id')

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

    def __repr__(self) -> str:
        return str(self) class AbstractAnswer(Document):
    """
    This is AbstractTask model.
    You need to inherit it in your model
    """
    task = ReferenceField(AbstractTask, reverse_delete_rule=CASCADE)
    created_by = ReferenceField(User, reverse_delete_rule=CASCADE,
                                db_field='createdBy')
    created_at = DateTimeField(db_field='createdAt')
    task_type = StringField(max_length=50, required=True, db_field='taskType')
    # not sure - could be extended
    result = DictField()

    meta = {
        'collection': 'reports',
        'allow_inheritance': True,
        'indexes': [
            'task',
            'created_by',
            'created_at',
            {
                'fields': ['created_by', 'task'],
                'unique': True
            }
        ]
    }

    # TODO: decide, if we need it at all
    @property
    def corrections(self) -> int:
        """
        Returns whole amount of actions/corrections given by user in this
        particular answer.

        :return: Count of corrections in this answer
        :rtype: int
        """
        return 1

    @corrections.setter
    def corrections(self, value: int) -> None:
        pass

    @corrections.deleter
    def corrections(self) -> None:
        pass

    @classmethod
    def answers_numbers_by_tasks(cls, task_ids: List[str]) -> Dict[ObjectId, int]:
        """
        Groups answers, filtered by tasks they belong to, by user and count
        number of answers for every user.

        :param task_ids: List of tasks IDs
        :type task_ids: List[str]

        :return: Map having user IDs as keys and answers numbers as values
        :rtype: Dict[ObjectId, int]
        """
        return cls.objects(task__in=task_ids).item_frequencies('created_by')

    def as_dict(self) -> Dict[str, Dict]:
        """
        Converts the model-instance into a safe that will include also task
        and user.

        :rtype: Dict[str, Dict]
        """
        return {
            'task': self.task.as_dict(),
            'answer': self.result,
            'user': self.created_by.as_dict()
        }

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

    def __repr__(self) -> str:
        return 'Report [{} by {}]'.format(self.created_by, self.task) class Batch(Document):
    """
    Helper category to group tasks.
    """
    id = StringField(max_length=50, primary_key=True)
    task_type = StringField(max_length=50, required=True, db_field='taskType')
    tasks_count = IntField(default=0, required=True, db_field='tasksCount')
    tasks_processed = IntField(default=0, db_field='tasksProcessed')
    closed = BooleanField(default=False, required=False)
    batch_meta = DictField(db_field='batchMeta')

    meta = {
        'collection': 'batches',
        'allow_inheritance': True,
        'indexes': [
            'task_type',
            'closed'
        ]
    }

    @classmethod
    def task_done_in(cls, batch_id: str) -> BatchUpdateResult:
        """
        Increment needed values upon a task from the batch is done. In case if
        all tasks are finished – close the batch.

        :param batch_id: Batch ID
        :type batch_id: str

        :return: Aggregate which represents complex effect of the method
        :rtype: BatchUpdateResult
        """
        num_changed = 0
        batch = cls.objects.get(id=batch_id)  # type: Batch
        processed = batch.tasks_processed + 1

        if processed > batch.tasks_count:
            return BatchUpdateResult(success=False, closed=False)

        closed = processed == batch.tasks_count
        update_q = {'inc__tasks_processed': 1}

        if closed:
            update_q['set__closed'] = closed
            num_changed = cls \
                .objects(id=batch.id, closed=False) \
                .update(**update_q)

        if num_changed == 0:
            update_q.pop('set__closed', None)
            closed = False

            num_changed = batch.update(**update_q)
        elif closed:
            on_batch_done.send(batch)

        return BatchUpdateResult(success=num_changed > 0, closed=closed)

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

    def __repr__(self) -> str:
        return 'Batch [{id}] ({processed}/{count})'.format(
            id=self.id,
            processed=self.tasks_processed,
            count=self.tasks_count) 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 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 FakeType(AbstractGamifiedTaskType):
    task_model = FakeModel
    answer_model = FakeAnswer
    type_name = 'FakeGamifiedTaskType'
    template = 'tmpl.html'

    _name = 'Fake name'
    _description = 'Fake description' 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'
import unittest
from datetime import datetime
from decimal import Decimal
from unittest.mock import patch
from vulyk.app import TASKS_TYPES
from vulyk.blueprints.gamification import listeners
from vulyk.blueprints.gamification.core.rules import Rule
from vulyk.blueprints.gamification.core.state import (
    UserState, InvalidUserStateException)
from vulyk.blueprints.gamification.models.events import EventModel
from vulyk.blueprints.gamification.models.rules import (
    RuleModel, ProjectAndFreeRules)
from vulyk.blueprints.gamification.models.state import UserStateModel
from vulyk.blueprints.gamification.models.task_types import \
    POINTS_PER_TASK_KEY, COINS_PER_TASK_KEY
from vulyk.models.stats import WorkSession
from vulyk.models.tasks import AbstractTask, AbstractAnswer, Batch
from vulyk.models.user import User, Group
from .fixtures import FakeType
from ..base import BaseTest
from ..fixtures import FakeType as BaseFakeType
        self.assertEqual(ev.coins, Decimal('1.0'))
        self.assertEqual(ev.level_given, 1)
        self.assertEqual(ev.viewed, False)
        self.assertEqual(ev.achievements, [rule])

        self.assertEqual(state.points, ev.points_given)
        self.assertEqual(state.actual_coins, Decimal())
        self.assertEqual(state.potential_coins, ev.coins)
        self.assertEqual(state.level, 1)
        self.assertEqual(ev.timestamp, state.last_changed)
        self.assertEqual(state.achievements, {rule.id: rule})

    def test_double_allocation(self):
        task_type = FakeType({})
        TASKS_TYPES[task_type.type_name] = task_type

        batch = Batch(
            id='default',
            task_type=task_type.type_name,
            tasks_count=2,
            tasks_processed=0,
            batch_meta={
                POINTS_PER_TASK_KEY: 2.5,
                COINS_PER_TASK_KEY: 1.5
            }
        ).save()

        task1 = task_type.task_model(
            id='task1',
            task_type=task_type.type_name,
            batch=batch,
            closed=False,
            users_count=0,
            users_processed=[],
            users_skipped=[],
            task_data={'data': 'data'}).save()

        task2 = task_type.task_model(
            id='task2',
            task_type=task_type.type_name,
            batch=batch,
            closed=False,
            users_count=0,
            users_processed=[],
            users_skipped=[],
            task_data={'data': 'data'}).save()

        task_type.work_session_manager.start_work_session(task1, self.USER.id)
        task_type.on_task_done(self.USER, task1.id, {'result': 'result'})
        task_type.work_session_manager.start_work_session(task2, self.USER.id)
        task_type.on_task_done(self.USER, task2.id, {'result': 'result'})

        state = UserStateModel.get_or_create_by_user(user=self.USER)
        self.assertEqual(state.points, Decimal('5.0'))
        self.assertEqual(state.level, 1)
        self.assertEqual(state.actual_coins, Decimal())
        self.assertEqual(state.potential_coins, Decimal('3.0'))

        events = EventModel.objects \
            .filter(user=self.USER) \
            .order_by('-timestamp')
        self.assertEqual(len(events), 2)
        self.assertEqual(events[0].timestamp, state.last_changed)
        self.assertEqual(events[0].level_given, None)
        self.assertEqual(events[1].level_given, 1)

        for ev in events:
            self.assertEqual(ev.points_given, Decimal('2.5'))
            self.assertEqual(ev.coins, Decimal('1.5'))
            self.assertEqual(ev.achievements, [])
            self.assertEqual(ev.viewed, False)

    def test_wrong_allocation(self):
        task_type = FakeType({})
        TASKS_TYPES[task_type.type_name] = task_type

        batch = Batch(
            id='default',
            task_type=task_type.type_name,
            tasks_count=1,
            tasks_processed=0,
            batch_meta={
                POINTS_PER_TASK_KEY: -1.0,
                COINS_PER_TASK_KEY: -1.0
            }
        ).save()

        task = task_type.task_model(
            id='task0',
            task_type=task_type.type_name,
            batch=batch,
            closed=False,
            users_count=0,
            users_processed=[],
            users_skipped=[],
            task_data={'data': 'data'}).save()

        task_type.work_session_manager.start_work_session(task, self.USER.id)
        self.assertRaises(
            InvalidUserStateException,
            lambda: task_type.on_task_done(self.USER,
                                           task.id,
                                           {'result': 'result'})
        )

    def test_double_allocation_different_batches(self):
        task_type = FakeType({})
        TASKS_TYPES[task_type.type_name] = task_type

        batch1 = Batch(
            id='default',
            task_type=task_type.type_name,
            tasks_count=1,
            tasks_processed=0,
            batch_meta={
                POINTS_PER_TASK_KEY: 2.5,
                COINS_PER_TASK_KEY: 1.5
            }
        ).save()


        batch2 = Batch(