# -*- coding: utf-8 -*-
import datetime
import logging
import re
import sys
import six
import time
import json
from collections import OrderedDict
from copy import deepcopy
import yaml

from django.db import models, transaction, DatabaseError
from django.db.models.constants import LOOKUP_SEP
from django.db.models.deletion import DO_NOTHING

from api.util.loadscheme import LoadSchemeIterator, PseudoLoadscheme
from common.util.meta import Ini2Json, xss_escape
from common.util.clients import ClickhouseClient
from common.util.decorators import memoized_property, cached
from settings import BASE_DIR

from bs4 import BeautifulSoup
from configparser import Error as ConfigParserError


class Task(models.Model):
    n = models.AutoField(primary_key=True)
    key = models.CharField(max_length=64, null=1)

    @property
    def project(self):
        return self.key.split('-')[0]

    class Meta:
        db_table = 'task'


class ServerQuerySet(models.query.QuerySet):
    use_in_migrations = True

    def get_or_create(self, **kwargs):
        """
        Looks up an object with the given kwargs, creating one if necessary.
        Returns a tuple of (object, created), where created is a boolean
        specifying whether an object was created.
        Overridden behavior: returns first() on MultipleObjectsReturned
        """
        defaults = kwargs.pop('defaults', {})
        lookup = kwargs.copy()
        for f in self.model._meta.fields:
            if f.attname in lookup:
                lookup[f.name] = lookup.pop(f.attname)
        try:
            self._for_write = True
            return self.get(**lookup), False
        except self.model.MultipleObjectsReturned:
            return self.first(), False
        except self.model.DoesNotExist:
            try:
                params = dict((k, v) for k, v in list(kwargs.items()) if LOOKUP_SEP not in k)
                params.update(defaults)
                obj = self.model(**params)
                with transaction.atomic(using=self.db):
                    obj.save(force_insert=True, using=self.db)
                return obj, True
            except DatabaseError:
                exc_info = sys.exc_info()
                try:
                    return self.get(**lookup), False
                except self.model.DoesNotExist:
                    # Re-raise the DatabaseError with its original traceback.
                    six.reraise(*exc_info)


class ServerManager(models.Manager):
    def get_query_set(self):
        return ServerQuerySet(self.model)


class Server(models.Model):
    n = models.AutoField(primary_key=True)
    host = models.CharField(max_length=128)
    is_test = models.PositiveSmallIntegerField(1, default=0)
    fd = models.DateTimeField(default=datetime.datetime.now)
    td = models.DateTimeField(null=1, default=None)  # default='0000-00-00 00:00:00')
    config = models.TextField(null=1, default='')
    dsc = models.TextField(null=1, default='')
    last_ip = models.CharField(max_length=20, default='')
    last_dc = models.SmallIntegerField(2, default=0)

    objects = ServerManager()

    class Meta:
        db_table = 'server'

    @property
    def name(self):
        if self.dsc:
            return self.dsc
        else:
            return self.host


class TaskServer(models.Model):
    """
    task and openstack server staple
    """
    n = models.AutoField(primary_key=True)
    task = models.CharField(max_length=120, null=1, default='')
    openstack_id = models.CharField(max_length=64, null=1)
    status = models.CharField(max_length=16, null=1, default='')

    class Meta:
        db_table = 'task_server'


class JobManager(models.Manager):
    online_kw = {'td__isnull': True}

    def online(self):
        return self.filter(**self.online_kw)


class Job(models.Model):
    n = models.AutoField(primary_key=True)
    task = models.CharField(max_length=120, null=1, default='')
    fd = models.DateTimeField(default=datetime.datetime.now)
    td = models.DateTimeField(null=1, default=None)
    person = models.CharField(max_length=64, null=1, default='')
    name = models.CharField(max_length=120, null=1, default='')
    dsc = models.TextField(null=1, default='')
    tank = models.ForeignKey(Server, to_field='n', db_column='tank', related_name='tank_job', default=None,
                             null=1, on_delete=DO_NOTHING)  # PositiveIntegerField(10)
    command_line = models.CharField(max_length=512, null=1, default='')
    ammo_path = models.CharField(max_length=256, null=1, default='')
    loop_cnt = models.FloatField(null=1, default=0)
    quit_status = models.PositiveSmallIntegerField(1, null=1, default=0)
    srv = models.ForeignKey(Server, to_field='n', db_column='srv', related_name='target_job', default=None,
                            null=1, on_delete=DO_NOTHING)  # PositiveIntegerField(10)
    srv_port = models.PositiveIntegerField(8, default=0)
    instances = models.PositiveIntegerField(8, default=0)
    flag = models.PositiveSmallIntegerField(1, default=0)
    component = models.PositiveIntegerField(6, default=0)
    ver = models.CharField(max_length=120, null=1, default='')
    configinfo = models.TextField(null=1, default='')
    configinitial = models.TextField(null=1, default='')
    finalized = models.PositiveSmallIntegerField(default=1)
    status = models.CharField(max_length=32, default='')

    @property
    def id(self):
        """
        n alias
        :return: int instead of long
        """
        return int(self.n)

    @property
    def author(self):
        """
        person alias
        """
        return self.person

    @property
    def start(self):
        """
        fd alias
        """
        return self.fd

    @property
    def is_online(self):
        return not bool(self.td)

    @memoized_property
    def basic_query_params(self):
        return {'job': self.n,
                'job_date': int(time.mktime(self.fd.timetuple())),
                }

    @property
    def srv_reduced(self):
        """
        returns list of job.srv without "yandex.ru" or "yandex.net"
        """
        if self.srv:
            return self.srv.host\
                .replace('.yandex.ru', '')\
                .replace('.yandex.net', '')\
                .replace('.yandex-team.ru', '')\
                .replace('.yndx.net', '')
        else:
            return None

    @property
    def tank_reduced(self):
        """
        returns list of job.srv without "yandex.ru" or "yandex.net"
        """
        if self.tank:
            return self.tank.host\
                .replace('.yandex.ru', '')\
                .replace('.yandex.net', '')\
                .replace('.yandex-team.ru', '')\
                .replace('.yndx.net', '')
        else:
            return None

    @memoized_property
    def loadschemes(self):
        """
        List of loadscheme objects (or pseudo loadscheme objects if retrieved from configinfo)
        """
        try:
            loadschemes = [ls for ls in LoadScheme.objects.filter(
                up=self.n,
                sec_from__lt=(
                    self.data_stopped_unix -
                    self.data_started_unix)
            ).order_by('sec_from')
                           ]
            assert loadschemes
            return loadschemes
        except AssertionError:
            try:
                configinfo_json = self.config
                phantom_section = configinfo_json.get('phantom', {})
                bfg_section = configinfo_json.get('bfg', {})
                schedule = phantom_section.get('rps_schedule', '') \
                    or bfg_section.get('rps_schedule', '') \
                    or phantom_section.get('instances_schedule', '') \
                    or bfg_section.get('instances_schedule', '')
                schedule = schedule.strip()
                if re.match(r'%\([a-zA-Z_]+\)s', schedule):
                    schedule = configinfo_json.get('DEFAULT', {}) \
                        .get(schedule[schedule.index('(') + 1:schedule.index(')')], '')
                if schedule.strip():
                    return [
                        PseudoLoadscheme(**ls)
                        for ls in LoadSchemeIterator(
                            (l for l in schedule.replace(' ', '').replace(')', ') ').split(' ') if l),
                            self.n
                        )
                    ]
                else:
                    logging.warning('No loadscheme for job {}'.format(self.n))
                    return []
            except:
                logging.exception('')
                return []

    @memoized_property
    def scheme_type(self):
        """
        looks for scheme_type in memcached (rps or instances)
        asks database if there's no data in memcached
        """
        cache_key = 'job_{}_scheme_type_'.format(self.n)

        @cached(cache_key, bypass=self.td is None)  # bypass для онлайн стрельб
        def fetch():
            ch_client = ClickhouseClient()
            nonzero_reqps = ch_client.select('''
                    select any(job_id)
                    from loaddb.rt_microsecond_details_buffer
                    where job_id={job}
                    and job_date=toDate({job_date})
                    and reqps!=0
                ''', query_params=self.basic_query_params)
            nonzero_threads = ch_client.select('''
                    select any(job_id)
                    from loaddb.rt_microsecond_details_buffer
                    where job_id={job}
                    and job_date=toDate({job_date})
                    and threads!=0
                ''', query_params=self.basic_query_params)
            if nonzero_reqps:
                scheme_type = 'rps'
            elif not nonzero_reqps and not nonzero_threads:
                # Для случаев ручной заливки пхаута
                scheme_type = 'rps'
            else:
                scheme_type = 'instances'
            return scheme_type

        try:
            return fetch()
        except:
            logging.exception('Could not check reqps for job {} due to:'.format(self.n))
            return 'rps'

    @memoized_property
    def targets(self):
        """
        returns tuple of tuples
        """
        try:
            sql = '''select distinct target_host
                    from loaddb.monitoring_verbose_data_buffer
                    where job_id={job}
                    and job_date=toDate({job_date})
                    '''
            ch_client = ClickhouseClient()
            fetched_data = [number[0] for number in ch_client.select(sql, query_params=self.basic_query_params) if
                            number and number[0]]
            targets = [Server.objects.get_or_create(host=host)[0] for host in fetched_data]
            if not targets:
                targets = [self.srv]
            return targets
        except:
            logging.exception("Could not get targets for job {}. Taking job srv as target".format(self.n))
            targets = [self.srv]
            return targets

    @memoized_property
    def cases(self):
        """
        returns a sorted list of cases
        """
        try:
            sql = '''
                select distinct tag
                from loaddb.rt_microsecond_details_buffer
                where job_id={job}
                and job_date=toDate({job_date})
                and tag!=''
                order by tag'''
            ch_client = ClickhouseClient()
            cases = [xss_escape(case[0]) for case in ch_client.select(sql, query_params=self.basic_query_params)]
        except:
            logging.exception("Could not get job {} cases due to:".format(self.n))
            cases = []
        return cases

    @memoized_property
    def multitag(self):
        return self.config.get('meta', {}).get('multitag', '') == 'true' or \
               self.config.get('uploader', {}).get('meta', {}).get('multitag', '') is True

    @property
    def tags(self):
        delimiter = '|'
        tags = []
        for case in self.cases:
            tags.extend(case.split(delimiter) if self.multitag else [case])
        return sorted(set(tags))

    @memoized_property
    def mobile_data_key(self):
        key = MobileJobDataKey.objects.filter(job=self)
        if key.count():
            return key[0].mobile_data_key
        else:
            return ''

    @property
    def monitoring_exists(self):
        """
        checks if monitoring exists for job
        looks for data in monitoring offline data
        """
        ch_client = ClickhouseClient()
        sql = '''select any(value)
                from loaddb.monitoring_verbose_data_buffer
                where job_id={job}
                and job_date=toDate({job_date})'''
        exists = ch_client.select(sql, query_params=self.basic_query_params)
        return bool(exists)

    @memoized_property
    def monitoring_only(self):
        """
        for jobs with no data besides monitoring (i.e. mobile stand)
        """
        ch_client = ClickhouseClient()
        data_is_present = bool(ch_client.select('''
            select
            any(job_id) as j
            from loaddb.rt_microsecond_details_buffer
            where job_id={job}
            and job_date=toDate({job_date})
            ''', query_params=self.basic_query_params))
        monitoring_is_present = bool(ch_client.select('''
            select
            any(job_id) as j
            from loaddb.monitoring_verbose_data_buffer
            where job_id={job}
            and job_date=toDate({job_date})
            ''', query_params=self.basic_query_params))

        return bool(monitoring_is_present and not data_is_present)

    @property
    def imbalance(self):
        try:
            imbalance = JobImbalance.objects.filter(up=self.n).order_by('rob_isimbalance', '-hum_isimbalance', '-n')[
                        0:1].get()
            if imbalance.hum_imbalance:
                return imbalance.hum_imbalance
            elif imbalance.rob_imbalance:
                return imbalance.rob_imbalance
            else:
                return 0
        except JobImbalance.DoesNotExist:
            if self.td:
                logging.warning('JobImbalance DoesNotExist for offline job {}'.format(self.n))
            return 0
        except:
            logging.exception('problems while processing job imbalance for job {}:'.format(self.n))
            return 0

    @property
    def duration(self):
        """
        returns int
        self.data_stopped - self.data_started for finished jobs
        last trail or now time - self.data_started for online jobs
        """
        if self.td:
            return int(self.data_stopped_unix - self.data_started_unix + 1)
        else:
            try:
                sql_last_trail = '''
                    select toInt32(max(time))
                    from loaddb.rt_microsecond_details_buffer
                    where job_id={job}
                    and job_date=toDate({job_date})
                    '''
                ch_client = ClickhouseClient()
                last_trail_time = ch_client.select(sql_last_trail, query_params=self.basic_query_params)
                last_trail_time = last_trail_time[0][0] if last_trail_time else int(time.time())
                return last_trail_time - int(self.data_started_unix)
            except Exception:
                logging.warning('Could not get online Job {} duration due to:'.format(self.n), exc_info=True)
                return 0

    @property
    def duration_formatted(self):
        """
        i.e. 3d 13:30:15
        """
        res = ''
        days, rest = divmod(self.duration, 3600 * 24)
        if days > 0:
            res += '{}d '.format(days)
        res += str(time.strftime('%H:%M:%S', time.gmtime(rest)))
        return res

    @property
    def estimated_duration(self):
        """
        regarding loadscheme
        """
        try:
            job_loadschemes = LoadScheme.objects.filter(up=self)
            assert job_loadschemes
            job_loadscheme_sec_from = min([loadscheme.sec_from for loadscheme in job_loadschemes])
            job_loadscheme_sec_to = max([loadscheme.sec_to for loadscheme in job_loadschemes])
            return job_loadscheme_sec_to - job_loadscheme_sec_from
        except JobImbalance.DoesNotExist:
            logging.warning('No LoadScheme for online job {}'.format(self.n))
            return 0
        except AssertionError:
            return 0
        except:
            logging.exception('Could not get Job {} estimated duration due to:'.format(self.n))
            return 0

    @property
    def quit_status_text(self):
        """
        converting quit_status codes into text
        """
        job_quit_status_interpretation = {
            '0': 'completed',
            '1': 'interrupted_generic_interrupt',
            '2': 'interrupted',
            '3': 'interrupted_active_task_not_found ',
            '4': 'interrupted_no_ammo_file',
            '5': 'interrupted_address_not_specified',
            '6': 'interrupted_cpu_or_disk_overload',
            '7': 'interrupted_unknown_config_parameter',
            '8': 'interrupted_stop_via_web',
            '9': 'interrupted',
            '11': 'interrupted_job_number_error',
            '12': 'interrupted_phantom_error',
            '13': 'interrupted_job_metainfo_error',
            '14': 'interrupted_target_monitoring_error',
            '15': 'interrupted_target_info_error',
            '21': 'autostop_time',
            '22': 'autostop_http',
            '23': 'autostop_net',
            '24': 'autostop_instances',
            '25': 'autostop_total_time',
            '26': 'autostop_total_http',
            '27': 'autostop_total_net',
            '28': 'autostop_negative_http',
            '29': 'autostop_negative_net',
            '30': 'autostop_http_trend',
            '31': 'autostop_metric_higher',
            '32': 'autostop_metric_lower',
            '100': 'killed_as_stale',
        }
        try:
            if str(self.quit_status) in list(job_quit_status_interpretation.keys()):
                return job_quit_status_interpretation[str(self.quit_status)]
            elif self.quit_status is None:
                return 'online'
            else:
                return 'other'
        except:
            logging.exception('Could not get quit_status for job {} due to:'.format(self.n))
            return 'other'

    @memoized_property
    def data_started(self):
        """
        for online jobs returns first trail or job.fd
        for offline jobs returns JobTrail.trail_start or first Trail.time or job.fd
        """
        try:
            job_trail = JobTrail.objects.get(up=self)
            return job_trail.trail_start
        except (JobTrail.DoesNotExist, JobTrail.MultipleObjectsReturned):
            logging.debug('No JobTrail or multiple JobTrails for offline job %s; '
                          'define data_started by clickhouse data', self.n)
            try:
                sql_first_trail = '''select min(time)
                    from loaddb.rt_microsecond_details_buffer
                    where job_id={job}
                    and job_date=toDate({job_date})
                    and time!='0000-00-00 00:00:00'
                    '''
                ch_client = ClickhouseClient()
                first_trail_time = ch_client.select(sql_first_trail, query_params=self.basic_query_params)[0][0]
                if first_trail_time == '0000-00-00 00:00:00':
                    first_trail_time = '0001-01-01 00:00:00'
                data_started = datetime.datetime.strptime(first_trail_time, "%Y-%m-%d %H:%M:%S")
                logging.debug('Data_started for offline job %s is %s', self.n, data_started)
                return data_started
            except IndexError:
                logging.debug('No first trail for offline job %s,take job.fd as data_started', self.n)
                return self.fd
        except Exception:
            logging.debug('Could not get Job %s data_started due to ', self.n, exc_info=True)
            return self.fd

    @memoized_property
    def data_started_unix(self):
        return int(time.mktime(datetime.datetime.timetuple(self.data_started)))

    @property
    def data_stopped(self):
        """
        for online jobs returns None
        for offline jobs returns JobTrail.trail_stop or last Trail.time or job.td
        """
        try:
            assert self.td
            job_trail = JobTrail.objects.get(up=self)
            return job_trail.trail_stop
        except AssertionError:
            logging.warning("requested data_stopped property for online job {}".format(self.n))
            return None
        except (JobTrail.DoesNotExist, JobTrail.MultipleObjectsReturned):
            logging.debug('No JobTrail or multiple JobTrails for offline job %s '
                          'define data_stopped by clickhouse data', self.n)
            try:
                sql_last_trail = '''select max(time)
                                    from loaddb.rt_microsecond_details_buffer
                                    where job_id={job}
                                    and job_date=toDate({job_date})
                                    and time!='0000-00-00 00:00:00'
                                    '''
                ch_client = ClickhouseClient()
                last_trail_time = ch_client.select(sql_last_trail, query_params=self.basic_query_params)[0][0]
                if last_trail_time == '0000-00-00 00:00:00':
                    last_trail_time = '0001-01-01 00:00:00'
                return datetime.datetime.strptime(last_trail_time, "%Y-%m-%d %H:%M:%S")
            except IndexError:
                logging.debug('No Trail for offline job %s, get job.td as data_stopped', self.n)
                return self.td
        except Exception:
            logging.debug('Could not get Job %s data_stopped due to:', self.n, exc_info=True)
            return self.td

    @property
    def data_stopped_unix(self):
        return int(time.mktime(datetime.datetime.timetuple(self.data_stopped))) if self.data_stopped else None

    @property
    def config(self):
        config_str = self.configinitial or self.configinfo
        try:
            return Ini2Json(config_str).convert()
        except ConfigParserError:
            try:
                config_str = yaml.safe_load(config_str.replace('!!python/unicode ', ''))
                assert isinstance(config_str, dict), 'Invalid config {}'.format(config_str)
                return deepcopy(config_str)
            except (yaml.constructor.ConstructorError, yaml.parser.ParserError, AssertionError) as exc:
                logging.error(repr(exc))
                return {}

    @property
    def config_fmt(self):
        config = self.configinitial or self.configinfo
        try:
            Ini2Json(config).convert()
            return 'ini'
        except ConfigParserError:
            try:
                yaml.safe_load(config.replace('!!python/unicode ', ''))
                return 'yaml'
            except (ValueError, yaml.parser.ParserError):
                return None

    @memoized_property
    def tankapi_jobno(self):
        config_str = self.configinfo or ''
        info = {}
        try:
            assert config_str
            info = deepcopy(Ini2Json(config_str).convert())
        except AssertionError as aexc:
            logging.error(repr(aexc))
        except ConfigParserError:
            try:
                config_str = yaml.safe_load(config_str.replace('!!python/unicode ', ''))
                assert isinstance(config_str, dict), 'Invalid configinfo {}'.format(config_str)
                info = deepcopy(config_str)
            except (yaml.constructor.ConstructorError, yaml.parser.ParserError, AssertionError) as exc:
                logging.error(repr(exc))

        return info.get('tank', {}).get('api_jobno', '') or info.get('core', {}).get('api_jobno', '')


    @property
    def uses_jmeter(self):
        return bool(self.config.get('tank', {}).get('plugin_jmeter')) \
               or bool(self.config.get('jmeter', {}).get('enabled'))

    objects = JobManager()

    class Meta:
        db_table = 'job'


class MobileJobDataKey(models.Model):
    job = models.ForeignKey(Job, on_delete=DO_NOTHING)
    mobile_data_key = models.CharField(max_length=256)

    class Meta:
        db_table = 'mobile_data_key'


class TankUserAgent(models.Model):
    job = models.ForeignKey(Job, on_delete=DO_NOTHING)
    user_agent = models.CharField(max_length=512)


class JobMonitoringConfig(models.Model):
    job = models.ForeignKey(Job, on_delete=DO_NOTHING)
    contents = models.TextField(null=1, default='')

    class Meta:
        db_table = 'job_monitoring_config'


class JobEvent(models.Model):
    job = models.ForeignKey(Job, on_delete=DO_NOTHING)
    text = models.TextField(null=1, default='')
    author = models.CharField(max_length=32, null=1)
    timestamp = models.DateTimeField(null=1)
    tag = models.CharField(max_length=512, null=1, default='')

    class Meta:
        db_table = 'job_event'


class DeletedJob(models.Model):
    n = models.PositiveIntegerField(primary_key=True)
    task = models.CharField(max_length=120, null=1, default='')
    fd = models.DateTimeField(default=datetime.datetime.now)
    td = models.DateTimeField(null=1, default=None)
    person = models.CharField(max_length=64, null=1, default='')
    name = models.CharField(max_length=120, null=1, default='')
    dsc = models.TextField(null=1, default='')
    tank = models.ForeignKey(Server, to_field='n', db_column='tank', related_name='tank', default=None,
                             null=1, on_delete=DO_NOTHING)  # PositiveIntegerField(10)
    command_line = models.CharField(max_length=512, null=1, default='')
    ammo_path = models.CharField(max_length=256, null=1, default='')
    loop_cnt = models.FloatField(null=1, default=0)
    quit_status = models.PositiveSmallIntegerField(1, null=1, default=0)
    srv = models.ForeignKey(Server, to_field='n', db_column='srv', related_name='srv', default=None,
                            null=1, on_delete=DO_NOTHING)  # PositiveIntegerField(10)
    srv_port = models.PositiveIntegerField(8, default=0)
    instances = models.PositiveIntegerField(8, default=0)
    flag = models.PositiveSmallIntegerField(1, default=0)
    component = models.PositiveIntegerField(6, default=0)
    ver = models.CharField(max_length=120, null=1, default='')
    configinfo = models.TextField(null=1, default='')
    configinitial = models.TextField(null=1, default='')
    finalized = models.PositiveSmallIntegerField(1, default=1)
    status = models.CharField(max_length=32, default='')

    class Meta:
        db_table = 'deleted_job'


class JobImbalance(models.Model):
    n = models.AutoField(primary_key=True)
    up = models.ForeignKey(Job, to_field='n', db_column='up', on_delete=models.CASCADE)
    hum_isimbalance = models.IntegerField(1, default=0)
    hum_imbalance = models.IntegerField(8, default=0)
    rob_isimbalance = models.IntegerField(1, default=0)
    rob_warning_sec = models.IntegerField(8, default=0)
    rob_imbalance = models.IntegerField(8, default=0)
    rob_imbalance_sec = models.IntegerField(8, default=0)
    hum_processed = models.IntegerField(1, default=0)
    user = models.CharField(max_length=64, null=1)

    class Meta:
        db_table = 'job_imbalance'


class LoadScheme(models.Model):
    n = models.AutoField(primary_key=True)
    up = models.ForeignKey(Job, to_field='n', db_column='up',  on_delete=DO_NOTHING)
    sec_from = models.PositiveIntegerField(10)
    sec_to = models.PositiveIntegerField(10)
    load_type = models.PositiveIntegerField(2)
    load_from = models.FloatField(2)
    load_to = models.FloatField(2)
    dsc = models.CharField(max_length=128)

    class Meta:
        db_table = 'loadscheme'


class CustomUserReport(models.Model):
    n = models.AutoField(primary_key=True)
    user = models.CharField(max_length=64)
    name = models.CharField(max_length=32)
    active = models.PositiveSmallIntegerField(1, default=1)
    plots_json = models.TextField(null=1, default='[]')

    @property
    def plots(self):
        if self.plots_json is not None:
            return json.loads(self.plots_json)
        else:
            return []

    @classmethod
    def plots_order(cls):
        """
        Порядок графиков для отображения.
        :return: list
        """
        try:
            with open(BASE_DIR + '/www/offlinepage/templates/custom_report_form.html') as f:
                html = f.read()
            parsed_html = BeautifulSoup(html)
            return [dict(element.attrs)['name'] for element in parsed_html.findAll('input', attrs={'type': 'checkbox'})]
        except:
            logging.exception('')
            return []

    class Meta:
        db_table = 'custom_user_report'


class UploadToken(models.Model):
    n = models.AutoField(primary_key=True)
    job = models.BigIntegerField(10, null=True)
    token = models.CharField(max_length=64, default='')

    class Meta:
        db_table = 'upload_token'


# ===============================================================================
# Регрессия
# ===============================================================================

class Component(models.Model):
    n = models.AutoField(primary_key=True)
    tag = models.CharField(max_length=128, null=1)
    name = models.CharField(max_length=128)
    dsc = models.TextField(null=1)
    include_qs = models.CharField(max_length=128, default='')
    exclude_qs = models.CharField(max_length=128, default='')
    job_order = models.CharField(max_length=32, default='n_ordered')
    priority = models.SmallIntegerField(1, default=0)
    services_json = models.TextField(null=1, default='[]')

    @property
    def jobs(self):
        jobs = Job.objects.filter(component=self.n)
        return jobs

    @property
    def services(self):
        try:
            return json.loads(self.services_json)
        except Exception:
            return []

    class Meta:
        db_table = 'component'


class KPI(models.Model):
    n = models.AutoField(primary_key=True)
    ktype = models.CharField(max_length=128, default='')
    component_id = models.PositiveIntegerField(6)
    essential = models.PositiveSmallIntegerField(1, default=0)
    params_json = models.TextField(null=1)
    dsc = models.TextField(null=1)

    kpitype_map = OrderedDict((
        ('imbalance', 'RPS разладки'),
        ('job_trail__quantiles', 'Кумулятивные квантили'),
        ('trail__resps', 'Ответы в секунду'),
        ('trail__input', 'Input'),
        ('trail__output', 'Output'),
        ('trail__expect', 'Ср. время ответа'),
        ('trail__connect_time', 'Ср. время соединения'),
        ('trail__send_time', 'Ср. время отправки'),
        ('trail__latency', 'Ср. время отклика'),
        ('trail__receive_time', 'Ср.время получения'),
        ('trail__threads', 'Потоки'),
        ('trail_net__count', 'Сетевой код (количество ответов)'),
        ('trail_net__percent', 'Сетевой код (доля ответов)'),
        ('trail_resp__count', 'HTTP код (количество ответов)'),
        ('trail_resp__percent', 'HTTP код (доля ответов)'),
        ('trail_time__count', 'Время ответа (количество ответов)'),
        ('trail_time__percent', 'Время ответа (доля ответов)'),
        ('trail_time__quantile', 'Время ответа (квантиль)'),
        ('monitoring__cpu', 'Мониторинг: CPU'),
        ('monitoring__memory', 'Мониторинг: Memory'),
        ('monitoring__disk', 'Мониторинг: Disk'),
        ('monitoring__system', 'Мониторинг: System'),
        ('monitoring__net', 'Мониторинг: Net'),
        ('monitoring__custom', 'Мониторинг: custom'),
    ))

    # ОМГ обратная совместимость
    kpicode_map = OrderedDict((
        ('imbalance', 1),
        ('job_trail__quantiles', 2),
        ('trail__resps', 4),
        ('trail__input', 5),
        ('trail__output', 6),
        ('trail__expect', 7),
        ('trail__connect_time', 8),
        ('trail__send_time', 9),
        ('trail__latency', 10),
        ('trail__receive_time', 11),
        ('trail__threads', 12),
        ('trail_net__count', 13),
        ('trail_net__percent', 14),
        ('trail_resp__count', 15),
        ('trail_resp__percent', 16),
        ('trail_time__count', 17),
        ('trail_time__percent', 18),
        ('trail_time__quantile', 19),
        ('monitoring__cpu', 20),
        ('monitoring__memory', 21),
        ('monitoring__disk', 22),
        ('monitoring__system', 23),
        ('monitoring__net', 24),
        ('monitoring__custom', 25),
    ))

    @property
    def kpitype_dsc(self):
        return self.kpitype_map.get(self.ktype, '')

    @property
    def params(self):
        if self.params_json is not None:
            kp = json.loads(self.params_json)
            if isinstance(kp, dict):
                for k, v in kp.items():
                    if k == 'target':
                        kp[k] = Server.objects.get(n=v)
                    elif k == 'metric':
                        from monitoring.models import Metric
                        kp[k] = Metric.objects.get(id=v)
            return kp
        else:
            return {'case': '', 'graphs': [], 'sla': {}}

    class Meta:
        db_table = 'kpi'


class RegressionComment(models.Model):
    job = models.ForeignKey(Job, on_delete=DO_NOTHING)
    text = models.CharField(max_length=256, null=1)
    author = models.CharField(max_length=32)
    created_at = models.DateTimeField(null=1)

    class Meta:
        db_table = 'regression_comment'


# ===============================================================================
# AGGREGATES
# ===============================================================================

class JobTrail(models.Model):
    n = models.AutoField(primary_key=True)
    up = models.ForeignKey(Job, to_field='n', db_column='up', on_delete=DO_NOTHING)
    min_rps = models.IntegerField(10)
    max_rps = models.IntegerField(10)
    http = models.CharField(max_length=64)
    net = models.PositiveSmallIntegerField(1)
    avg_resps = models.FloatField(null=True)  # average rps
    trail_start = models.DateTimeField(null=1)  # first insert into trail
    trail_stop = models.DateTimeField(null=1)  # last insert into trail
    avg_expect = models.FloatField(null=1)
    avg_connect_time = models.FloatField(null=True)
    avg_send_time = models.FloatField(null=True)
    avg_latency = models.FloatField(null=True)
    avg_receive_time = models.FloatField(null=True)
    q50 = models.FloatField(null=1)
    q75 = models.FloatField(null=1)
    q80 = models.FloatField(null=1)
    q85 = models.FloatField(null=1)
    q90 = models.FloatField(null=1)
    q95 = models.FloatField(null=1)
    q98 = models.FloatField(null=1)
    q99 = models.FloatField(null=1)

    @property
    def duration_seconds(self):
        start = time.mktime(datetime.datetime.timetuple(self.trail_start))
        end = time.mktime(datetime.datetime.timetuple(self.trail_stop))
        return end - start

    def precise_set_to_job_trail(self, precise_data):
        try:
            quantiles = list(precise_data.keys())
            for quantile in quantiles:
                if quantile in dir(self) and quantile in ["q" + str(percentile) for percentile in range(101)]:
                    vars(self.__class__)[quantile] = precise_data[quantile]
            self.save()
        except:
            logging.exception('error while setting precise quantiles to job_trail for job {}:'.format(self.n))

    class Meta:
        db_table = 'job_trail'


# ===============================================================================
# MEDIA STORAGE
# ===============================================================================


class Artifact(models.Model):
    job = models.ForeignKey(Job, on_delete=DO_NOTHING)
    storage_key = models.CharField(max_length=256)

    class Meta:
        db_table = 'artifact'


class Ammo(models.Model):
    author = models.CharField(max_length=32)
    created_at = models.DateTimeField(null=0)
    dsc = models.CharField(max_length=128)
    flag = models.PositiveSmallIntegerField(1, default=0)
    hidden = models.PositiveSmallIntegerField(1, default=0)
    last_used = models.DateTimeField(null=0)
    mdsum = models.CharField(max_length=32, default='')
    path = models.CharField(max_length=256)
    private = models.PositiveSmallIntegerField(1, default=0)
    size = models.IntegerField(20, default=0)

    class Meta:
        db_table = 'ammo'


class PandoraBinary(models.Model):
    author = models.CharField(max_length=32)
    created_at = models.DateTimeField(null=0)
    dsc = models.CharField(max_length=128)
    flag = models.PositiveSmallIntegerField(1, default=0)
    hidden = models.PositiveSmallIntegerField(1, default=0)
    last_used = models.DateTimeField(null=0)
    mdsum = models.CharField(max_length=32, default='')
    path = models.CharField(max_length=256)
    private = models.PositiveSmallIntegerField(1, default=0)
    size = models.IntegerField(20, default=0)

    class Meta:
        db_table = 'pandora_binary'


class UISettings(models.Model):
    person = models.CharField(max_length=64)
    param = models.CharField(max_length=256)
    onoff = models.PositiveSmallIntegerField(1, default=1)
