class MongoRuleExecutor:
    """
    Simple query runner that uses pymongo's BaseQuerySet instance to aggregate
    user's stats.
    """
    __slots__ = []

    @staticmethod
    def achieved(user_id: ObjectId,
                 rule: Rule,
                 collection: BaseQuerySet) -> bool:
        """
        Determines if given user has achieved a new prize.

        :param user_id: Current user ID
        :type user_id: bson.ObjectId
        :param rule: Rule to be applied
        :type rule: Rule
        :param collection: WorkSession querySet
        :type collection: BaseQuerySet

        :return: True if user stats comply to the rule
        :rtype: bool
        """
        query = MongoRuleQueryBuilder(rule).build_for(user_id)
        cursor = collection.aggregate(*query)

        try:
            record = next(cursor)
            return record["achieved"] >= rule.limit
        except StopIteration:
            return False class MongoRuleQueryBuilder(RuleQueryBuilder):
    """
    Implementation of RuleQueryBuilder, bound to MongoDB.
    """
    __slots__ = []

    _Pair = namedtuple('Pair', ['key', 'clause'])

    def __init__(self, rule: Rule) -> None:
        """
        :param rule: Actual rule to be calculated.
        :type rule: Rule
        """
        super().__init__()

        self._filter_first['answer'] = {'$exists': True}

        if isinstance(rule, ProjectRule):
            self._filter_first['taskType'] = rule.task_type_name

        # we filter out tasks older than given date in these cases:
        # - n tasks in m days
        is_tasks_in_days = (rule.days_number or 0) > 0 and (rule.tasks_number or 0) > 0
        # - has been working for m adjacent days/weekends
        is_adjacent = (rule.days_number or 0) > 0 and rule.is_adjacent

        if is_tasks_in_days or is_adjacent:
            days_ago = timedelta(days=rule.days_number)

            if is_adjacent and rule.is_weekend:
                # multiply by week length
                days_ago *= 7

            then = datetime.combine(date.today() - days_ago,
                                    datetime.min.time())

            self._filter_first['end_time'] = {'$gt': then}

        # filter by tasks closed on Saturday or Sunday only
        if rule.is_weekend:
            self._projection = {
                'dayOfWeek': {'$dayOfWeek': '$end_time'},
                'year': {'$year': '$end_time'},
                'week': {'$week': '$end_time'},
            }
            # mongoDB aggregation framework takes Sunday as 1 and Saturday as 7
            self._filter_second = {'$or': [{'dayOfWeek': 7}, {'dayOfWeek': 1}]}

        # count by days with at least one task closed
        count_by_days = (rule.days_number or 0) > 0 and (rule.tasks_number or 0) == 0

        if count_by_days:
            if rule.is_weekend:
                self._group = {'_id': {'week': '$week', 'year': '$year'}}
            else:
                self._projection = {
                    'day': {'$dayOfMonth': '$end_time'},
                    'month': {'$month': '$end_time'},
                    'year': {'$year': '$end_time'}
                }
                self._group = {
                    '_id': {
                        'day': '$day',
                        'month': '$month',
                        'year': '$year'}
                }

    def build_for(self, user_id: ObjectId) -> List[Dict[str, Dict]]:
        """
        Prepares a pipeline of actions to be passed to MongoDB Aggregation
        Framework.

        :param user_id: Current user ID
        :type user_id: bson.ObjectId

        :returns: List of actions to be done within the aggregation.
        :rtype: List[Dict[str, Dict]]
        """
        filter_first = self._filter_first.copy()
        filter_first['user'] = user_id

        result = [{'$match': filter_first}]

        for statement in (
            self._Pair(key='$project', clause=self._projection),
            self._Pair(key='$match', clause=self._filter_second),
            self._Pair(key='$group', clause=self._group)
        ):
            if bool(statement.clause):
                result.append({statement.key: statement.clause})

        result.append({"$count": "achieved"})  # MongoDB 3.4+

        return result 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 ProjectRule(Rule):
    """
    Container for project specific rules.
    """
    __slots__ = Rule.__slots__ + [
        '_task_type_name'
    ]

    def __init__(self,
                 rule_id: str,
                 task_type_name: 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 task_type_name: Task type name.
        :type task_type_name: 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
        """
        super().__init__(rule_id=rule_id,
                         badge=badge,
                         name=name,
                         description=description,
                         bonus=bonus,
                         tasks_number=tasks_number,
                         days_number=days_number,
                         is_weekend=is_weekend,
                         is_adjacent=is_adjacent)

        self._task_type_name = task_type_name

    @property
    def task_type_name(self) -> str:
        return self._task_type_name

    @classmethod
    def from_rule(cls, rule: Rule, task_type_name: str) -> Rule:
        """
        Factory method to extend regular Rule and promote it to ProjectRule

        :param rule: Basic Rule instance to copy info from
        :type rule: Rule
        :param task_type_name: ID of the project/task type.
        :type task_type_name: str

        :return: Fully formed ProjectRule
        :rtype: ProjectRule
        """
        return cls(
            rule_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_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]]
        """
        result = super().to_dict()
        result['task_type_name'] = self.task_type_name

        return result

    def __str__(self) -> str:
        return 'ProjectRule({name}, {task_type}, {bonus}, {tasks}, {days},' \
               ' {week}, {adj})' \
            .format(name=self.name,
                    task_type=self._task_type_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 'ProjectRule({name}, {task_type}, {descr})'.format(
            name=self.name,
            task_type=self._task_type_name,
            descr=self.description[:50] + '...'
        )

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

    def __ne__(self, o: object) -> bool:
        return not self == o 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 AllRules(RuleFilter):
    """
    Should return all rules.
    """

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

        :return: Prepared query instance.
        :rtype: Q
        """
        return Q()

    def __eq__(self, o: object) -> bool:
        return isinstance(o, AllRules) 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 StrictProjectRules(RuleFilter):
    """
    Should return all rules related only to a certain project.
    """

    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)

    def __eq__(self, o: object) -> bool:
        if isinstance(o, StrictProjectRules):
            return self._task_type_name == o._task_type_name
        else:
            return False 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 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)
import unittest
from datetime import date, datetime, timedelta
from bson import ObjectId
from vulyk.blueprints.gamification.core.queries import (
    MongoRuleExecutor,
    MongoRuleQueryBuilder)
from vulyk.blueprints.gamification.core.rules import Rule, ProjectRule
from vulyk.blueprints.gamification.models.rules import (
    RuleModel, AllRules, ProjectAndFreeRules, StrictProjectRules)
from vulyk.models.stats import WorkSession
from ..base import BaseTest
        for i in range(1, 6):
            WorkSession(user=uid,
                        task=ObjectId(),
                        task_type='fake_task',
                        answer=ObjectId(),
                        start_time=self.NOW - self.DAY * i,
                        end_time=self.NOW - self.DAY * i).save()

        result = MongoRuleExecutor.achieved(
            user_id=uid, rule=rule, collection=WorkSession.objects)

        self.assertTrue(result)

    def test_m_days_adjacent_project_ok(self):
        uid = ObjectId()
        task_type_name = 'fake_task'
        rule = ProjectRule(
            rule_id='100',
            task_type_name=task_type_name,
            badge='',
            name='',
            description='',
            bonus=0,
            tasks_number=0,
            days_number=5,
            is_weekend=False,
            is_adjacent=True)

        for i in range(1, 6):
            WorkSession(user=uid,
                        task=ObjectId(),
                        answer=ObjectId(),
                        task_type=task_type_name,
                        start_time=self.NOW - self.DAY * i,
                        end_time=self.NOW - self.DAY * i).save()

        result = MongoRuleExecutor.achieved(
            user_id=uid, rule=rule, collection=WorkSession.objects)

        self.assertTrue(result)

    def test_m_days_adjacent_project_fail(self):
        uid = ObjectId()
        task_type_name = 'fake_task'
        rule = ProjectRule(
            rule_id='100',
            task_type_name=task_type_name,
            badge='',
            name='',
            description='',
            bonus=0,
            tasks_number=0,
            days_number=5,
            is_weekend=False,
            is_adjacent=True)

        for i in range(1, 6):
            WorkSession(user=uid,
                        task=ObjectId(),
                        answer=ObjectId(),
                        task_type='fake_task_%s' % i,
                        start_time=self.NOW - self.DAY * i,
                        end_time=self.NOW - self.DAY * i).save()

        result = MongoRuleExecutor.achieved(
            user_id=uid, rule=rule, collection=WorkSession.objects)

        self.assertFalse(result)


class TestRuleModel(BaseTest):
    RULES = [
        Rule(
            badge='',
            name='rule_1',
            description='',
            bonus=0,
            tasks_number=3,
            days_number=0,
            is_weekend=False,
            is_adjacent=False,
            rule_id='100'),
        Rule(
            badge='',
            name='rule_2',
            description='',
            bonus=10,
            tasks_number=30,
            days_number=0,
            is_weekend=False,
            is_adjacent=False,
            rule_id='200'),
        ProjectRule(
            rule_id='300',
            task_type_name='project_1',
            badge='',
            name='rule_3',
            description='',
            bonus=0,
            tasks_number=40,
            days_number=0,
            is_weekend=False,
            is_adjacent=False),
        ProjectRule(
            rule_id='400',
            task_type_name='project_2',
            badge='',
            name='rule_4',
            description='',
            bonus=0,
            tasks_number=0,
            days_number=5,
            is_weekend=False,
            is_adjacent=True),
    ]

    def setUp(self):
        super().setUp()

        for rule in self.RULES:

            RuleModel.from_rule(rule).save()