<commit_before>#!/usr/bin/env python
# encoding: utf-8

import logging
from enum import Enum
from functools import wraps
from tbone.dispatch.channels import Channel
from .formatters import JSONFormatter
from .authentication import NoAuthentication
from .verbs import *

logger = logging.getLogger(__file__)

class ResourceOptions(object):
    '''
    A configuration class for Resources. Provides all the defaults and
    allows overriding inside the resource's definition using the ``Meta`` class

    :param  name:
        Declare the resource's name. If ``None`` the class name will be used. Default is ``None``

    :param object_class:
        Declare the class of the underlying data object. Used in ``MongoResource`` to bind the resource class to a ``Model``

    :param query:
        Define a query which the resource will apply to all ``list`` calls. Used in ``MongoResource`` to apply a default query fiter.
        Useful for cases where the entire collection is never queried.

    :param sort:
        Define a sort directive which the resource will apply to GET requests without a unique identifier. Used in ``MongoResource`` to declare default sorting for collection.

    :param hypermedia:
        Specify if the a ``Resource`` should format data and include HATEOAS directives, specifically link to itself.
        Defaults to ``True``

    :param fts_operator:
        Define the FTS (full text search) operator used in url parameters. Used in ``MongoResource`` to perform FTS on a collection.
        Default is set to `q`.

    :param incoming_list:
        Define the methods the resource allows access to without a primary key.
        These are incoming request methods made to the resource.
        Defaults to a full access ``['get', 'post', 'put', 'patch', 'delete']``

    :param incoming_detail:
        Same as ``incoming_list`` but for requests which include a primary key

    :param outgoing_list:
        Define the resource events which will be emitted without a primary key.
        These are outgoing resource events which are emitted to subscribers.
        Defaults to these events ``['created', 'updated', 'deleted']``

    :param outgoing_detail:
        Same as ``outgoing_list`` but for resource events which include a primary key

    :param formatter:
        Provides an instance to a formatting class the resource will be using when formatting and parsing data.
        The default is ``JSONFormatter``. Developers can subclass ``Formatter`` base class and provide implementations to other formats.

    :param authentication:
        Provides and instance to the authentication class the resource will be using when authenticating requests.
        Default is ``NoAuthentication``.
        Developers must subclass the ``NoAuthentication`` class to provide their own resource authentication, based on the application's authentication choices.

    :param channel:
        Defines the Channel class which the resource will emit events into. Defaults to in-memory
    '''
    name = None
    object_class = None
    query = None
    sort = None
    hypermedia = True
    channel_class = Channel
    fts_operator = 'q'
    incoming_list = ['get', 'post', 'put', 'patch', 'delete']
    incoming_detail = ['get', 'post', 'put', 'patch', 'delete']
    outgoing_list = ['created', 'updated', 'deleted']
    outgoing_detail = ['created', 'updated', 'deleted']
    formatter = JSONFormatter()
    authentication = NoAuthentication()

    def __init__(self, meta=None):
        if meta:
            for attr in dir(meta):
                if not attr.startswith('_'):
                    setattr(self, attr, getattr(meta, attr))

class ResourceMeta(type):
    @classmethod
    def __prepare__(mcl, name, bases):
        ''' Adds the signal decorator so member methods can be decorated as signal receivers '''
        def receiver(signal):
            def _receiver(func):
                func._signal_receiver_ = signal

                @wraps(func)
                def wrapper(*args, **kwargs):
                    return func(*args, **kwargs)

                return wrapper
            return _receiver

        d = dict()
        d['receiver'] = receiver
        return d

    def __new__(mcl, name, bases, attrs):
        del attrs['receiver']
        cls = super(ResourceMeta, mcl).__new__(mcl, name, bases, attrs)

        # create default resource options
        options = ResourceOptions()
        # copy over resource options defined in base classes, if any
        for base in reversed(bases):
            if not hasattr(base, '_meta'):
                continue
            options.__dict__.update(base._meta.__dict__)

        # copy resource options defined in this resource, if any
        if hasattr(cls, 'Meta'):
            for attr in dir(cls.Meta):
                if not attr.startswith('_'):
                    setattr(options, attr, getattr(cls.Meta, attr))
        # create the combined resource options class
        cls._meta = ResourceOptions(options)
        return cls

class Resource(object, metaclass=ResourceMeta):
    '''
    Base class for all resources.
    '''
    responses = {
        'GET': OK,
        'POST': CREATED,
        'PUT': ACCEPTED,
        'PATCH': ACCEPTED,
        'DELETE': NO_CONTENT
    }
    status_map = {
        'list': OK,
        'detail': OK,
        'create': CREATED,
        'update': ACCEPTED,
        'delete': NO_CONTENT,
        'update_list': ACCEPTED,
        'create_detail': CREATED,
        'delete_list': NO_CONTENT
    }
    methods = {
        'list': {
            'GET': 'list',
            'POST': 'create',
            'PUT': 'update_list',
            'PATCH': 'modify_list',
            'DELETE': 'delete_list'
        },
        'detail': {
            'GET': 'detail',
            'POST': 'create_detail',
            'PUT': 'update',
            'PATCH': 'modify',
            'DELETE': 'delete'
        }
    }

    class Protocol(Enum):
        http = 10
        websocket = 20
        amqp = 30

    def __init__(self, *args, **kwargs):
        self.request = kwargs.get('request', None)
        self.endpoint = kwargs.get('endpoint', 'list')
        self.data = None

    def request_method(self):
        ''' Returns the HTTP method for the current request. '''
        return self.request.method.upper()

    def request_args(self):
        '''
        Returns the arguments passed with the request in a dictionary.
        Returns both URL resolved arguments and query string arguments.
        Implemented for specific http libraries in derived classes
        '''
        raise NotImplementedError()

    async def request_body(self):
        '''
        Returns the body of the current request.
        Implemented for specific http libraries in derived classes
        '''
        raise NotImplementedError()

    @classmethod
    def wrap_handler(cls, handler, protocol, **kwargs):
        ''' Wrap a request handler with the matching protocol handler '''
        def _wrapper(request, *args, **kwargs):
            instance = cls(request=request, **kwargs)
            if protocol == Resource.Protocol.http:
                return instance._wrap_http(handler, request=request, **kwargs)
            elif protocol == Resource.Protocol.websocket:
                return instance._wrap_ws(handler, request=request, **kwargs)
            elif protocol == Resource.Protocol.amqp:
                return instance._wrap_amqp(view_type, *args, **kwargs)
            else:
                raise Exception('Communication protocol not specified')
        return _wrapper

    @classmethod
    def as_view(cls, endpoint, protocol, *init_args, **init_kwargs):
        '''
        Used for hooking up the endpoints. Returns a wrapper function that creates
        a new instance of the resource class and calls the correct view method for it.
        '''
        def _wrapper(request, *args, **kwargs):
            ''' Make a new instance of the resource class and returns a coroutine for execution'''
            instance = cls(*init_args, endpoint=endpoint, request=request, **init_kwargs)
            if protocol == Resource.Protocol.http:
                return instance._wrap_http(cls.dispatch, endpoint=endpoint, *args, **kwargs)
            elif protocol == Resource.Protocol.websocket:
                return instance._wrap_ws(cls.dispatch, endpoint=endpoint, *args, **kwargs)
            elif protocol == Resource.Protocol.amqp:
                return instance._wrap_amqp(endpoint, *args, **kwargs)
            else:
                raise Exception('Communication protocol not specified')

        return _wrapper

    @classmethod
    def as_list(cls, protocol=Protocol.http, *args, **kwargs):
        ''' returns list views '''
        return cls.as_view('list', protocol, *args, **kwargs)

    @classmethod
    def as_detail(cls, protocol=Protocol.http, *args, **kwargs):
        ''' returns detail views '''
        return cls.as_view('detail', protocol, *args, **kwargs)

    async def _wrap_http(self, handler, *args, **kwargs):
        ''' wraps a handler with an HTTP request-response cycle'''
        try:
            method = self.request_method()
            # support preflight requests when CORS is enabled
            if method == 'OPTIONS':
                return self.build_http_response(None, status=NO_CONTENT)
            data = await handler(self, *args, **kwargs)
            # format the response object
            formatted = self.format(method, data)
            status = self.responses.get(method, OK)
            return self.build_http_response(formatted, status=status)
        except Exception as ex:
            return self.dispatch_error(ex)

    async def _wrap_ws(self, handler, *args, **kwargs):
        ''' wraps a handler by receiving a websocket request and returning a websocket response '''
        try:
            method = self.request_method()
            # call the wrapped handler
            data = await handler(self, *args, **kwargs)
            status = self.responses.get(method, OK)
            response = {
                'type': 'response',
                'key': data.pop('key', None),
                'status': status,
                'payload': data
            }
        except Exception as ex:
            response = {
                'type': 'response',
                'status': getattr(ex, 'status', 500),
                'error': getattr(ex, 'msg', 'general error')
            }
        finally:
            # return a formatted the response object
            return self.format(method, response)

    async def _wrap_amqp(self, handler, *args, **kwargs):
        return NotImplemented()

    @classmethod
    def nested_routes(cls, base_url, formatter: callable = None) -> list:
        '''
        Returns an array of ``Route`` objects which define additional routes on the resource.
        Implement in derived resources to add additional routes to the resource

        :param base_url:
            The URL prefix which will be prepended to all nested routes

        :param formatter:
            The format method to be used when parsing url variables.
            By default the resource's  ``route_param`` method is used, which formats the url based on which HTTP libary is used.

        '''
        return []

    def is_method_allowed(self, endpoint, method) -> bool:
        if endpoint == 'list':
            if method.lower() in self._meta.incoming_list:
                return True
        elif endpoint == 'detail':
            if method.lower() in self._meta.incoming_detail:
                return True
        return False

    async def dispatch(self, *args, **kwargs):
        '''
        This method handles the actual request to the resource.
        It performs all the neccesary checks and then executes the relevant member method which is mapped to the method name.
        Handles authentication and de-serialization before calling the required method.
        Handles the serialization of the response
        '''
        method = self.request_method()

        # get the db object associated with the app and assign to resource
        if hasattr(self.request.app, 'db'):
            setattr(self, 'db', self.request.app.db)

        # check if method is allowed
        if method not in self.methods.get(self.endpoint, {}):
            raise MethodNotImplemented("Unsupported method '{0}' for {1} endpoint.".format(method, self.endpoint))

        if self.is_method_allowed(self.endpoint, method) is False:
            raise MethodNotAllowed("Unsupported method '{0}' for {1} endpoint.".format(method, self.endpoint))

        # check user authentication
        if not await self._meta.authentication.is_authenticated(self.request):
            raise Unauthorized()

        # deserialize request data
        body = await self.request_body()
        self.data = self.parse(method, self.endpoint, body)
        if method != 'GET':
            self.data.update(kwargs)
        kwargs.update(self.request_args())
        view_method = getattr(self, self.methods[self.endpoint][method])
        # call request method
        data = await view_method(*args, **kwargs)
        # if a request key exists, add it to the data for response.
        # Used by the websocket subprotocol to link between requests and responses
        if hasattr(self.request, 'key'):
            data['key'] = self.request.key
        # add hypermedia to the response, if response is not empty
        if data and self._meta.hypermedia is True:
            if self.endpoint == 'list' and method == 'GET':
                for item in data['objects']:
                    self.add_hypermedia(item)
            else:
                self.add_hypermedia(data)

        return data

    def dispatch_error(self, err):
        '''
        Handles the dispatch of errors
        '''
        try:
            data = {'error': [l for l in err.args]}
            body = self._meta.formatter.format(data)
        except Exception as ex:
            data = {'error': str(err)}
            body = self._meta.formatter.format(data)

        status = getattr(err, 'status', 500)
        return self.build_http_response(body, status=status)

    @classmethod
    def build_http_response(cls, data, status=200):
        '''
        Given some data, generates an HTTP response.
        If you're integrating with a new web framework, other than sanic or aiohttp, you **MUST**
        override this method within your subclass.

        :param data:
             The body of the response to send
        :type data:
            string
        :param status:
            (Optional) The status code to respond with. Default is ``200``
        :type status:
            integer
        :returns: A response object
        '''
        raise NotImplementedError()

    @classmethod
    def route_methods(cls):
        '''
        Returns the relevant representation of allowed HTTP methods for a given route.
        Implemented on the http library resource sub-class to match the requirements of the HTTP library
        '''
        raise NotImplementedError()

    @classmethod
    def route_param(cls, param, type=str):
        '''
        Returns the route representation of a url param, pertaining to the web library used.
        Implemented on the http library resource sub-class to match the requirements of the HTTP library
        '''
        raise NotImplementedError()

    def get_resource_uri(self):
        return '/{}/{}/'.format(getattr(self.__class__, 'api_name', None), getattr(self.__class__, 'resource_name', None))

    def parse(self, method, endpoint, body):
        ''' calls parse on list or detail '''
        if isinstance(body, dict):  # request body was already parsed
            return body
        if endpoint == 'list':
            return self.parse_list(body)

        return self.parse_detail(body)

    def parse_list(self, body):
        if body:
            return self._meta.formatter.parse(body)
        return []

    def parse_detail(self, body):
        if body:
            return self._meta.formatter.parse(body)
        return {}

    def add_hypermedia(self, obj):
        '''
        Adds HATEOAS links to the resource. Adds href link to self.
        Override in subclasses to include additional functionality
        '''
        if hasattr(self, 'pk'):
            obj['_links'] = {
                'self': {
                    'href': '{}{}/'.format(self.get_resource_uri(), obj[self.pk])
                }
            }

    def format(self, method, data):
        ''' Calls format on list or detail '''
        if data is None:
            if method == 'GET':
                raise NotFound()
            return ''

        return self._meta.formatter.format(data)

    def format_list(self, data):
        if data is None:
            return ''
        return self._meta.formatter.format(data)

    def format_detail(self, data):
        if data is None:
            return ''
        return self._meta.formatter.format(data)

    @classmethod
    def connect_signal_receivers(cls):
        # connect signal receivers
        for item in dir(cls):
            attr = getattr(cls, item)
            if hasattr(attr, '_signal_receiver_'):
                logger.debug('signal receiver subscription', attr)
                attr._signal_receiver_.connect(attr, sender=cls._meta.object_class)

    #  methods which derived classes should implement
    async def list(self, **kwargs):
        raise MethodNotImplemented()

    async def detail(self, **kwargs):
        raise MethodNotImplemented()

    async def create(self, **kwargs):
        raise MethodNotImplemented()

    async def update(self, **kwargs):
        raise MethodNotImplemented()

    async def modify(self, **kwargs):
        raise MethodNotImplemented()

    async def delete(self, **kwargs):
        raise MethodNotImplemented()

    async def update_list(self, **kwargs):
        raise MethodNotImplemented()

    async def modify_list(self, **kwargs):
        raise MethodNotImplemented()

    async def create_detail(self, **kwargs):
        raise MethodNotImplemented()

    async def delete_list(self, **kwargs):
        raise MethodNotImplemented()

class ModelResource(Resource):
    '''
    A specialized resource class for using data models. Requires further implementation for data persistency
    '''

    def __init__(self, *args, **kwargs):
        super(ModelResource, self).__init__(*args, **kwargs)
        # verify object class has a declared primary key
        if not hasattr(self._meta.object_class, 'primary_key') or not hasattr(self._meta.object_class, 'primary_key_type'):
            raise Exception('Cannot create a ModelResource to model {} without a primary key'.format(self._meta.object_class.__name__))
        self.pk = self._meta.object_class.primary_key
        self.pk_type = self._meta.object_class.primary_key_type
<commit_msg>wrap_ws passes request key to response<commit_after>#!/usr/bin/env python
# encoding: utf-8

import logging
from enum import Enum
from functools import wraps
from tbone.dispatch.channels import Channel
from .formatters import JSONFormatter
from .authentication import NoAuthentication
from .verbs import *

logger = logging.getLogger(__file__)

class ResourceOptions(object):
    '''
    A configuration class for Resources. Provides all the defaults and
    allows overriding inside the resource's definition using the ``Meta`` class

    :param  name:
        Declare the resource's name. If ``None`` the class name will be used. Default is ``None``

    :param object_class:
        Declare the class of the underlying data object. Used in ``MongoResource`` to bind the resource class to a ``Model``

    :param query:
        Define a query which the resource will apply to all ``list`` calls. Used in ``MongoResource`` to apply a default query fiter.
        Useful for cases where the entire collection is never queried.

    :param sort:
        Define a sort directive which the resource will apply to GET requests without a unique identifier. Used in ``MongoResource`` to declare default sorting for collection.

    :param hypermedia:
        Specify if the a ``Resource`` should format data and include HATEOAS directives, specifically link to itself.
        Defaults to ``True``

    :param fts_operator:
        Define the FTS (full text search) operator used in url parameters. Used in ``MongoResource`` to perform FTS on a collection.
        Default is set to `q`.

    :param incoming_list:
        Define the methods the resource allows access to without a primary key.
        These are incoming request methods made to the resource.
        Defaults to a full access ``['get', 'post', 'put', 'patch', 'delete']``

    :param incoming_detail:
        Same as ``incoming_list`` but for requests which include a primary key

    :param outgoing_list:
        Define the resource events which will be emitted without a primary key.
        These are outgoing resource events which are emitted to subscribers.
        Defaults to these events ``['created', 'updated', 'deleted']``

    :param outgoing_detail:
        Same as ``outgoing_list`` but for resource events which include a primary key

    :param formatter:
        Provides an instance to a formatting class the resource will be using when formatting and parsing data.
        The default is ``JSONFormatter``. Developers can subclass ``Formatter`` base class and provide implementations to other formats.

    :param authentication:
        Provides and instance to the authentication class the resource will be using when authenticating requests.
        Default is ``NoAuthentication``.
        Developers must subclass the ``NoAuthentication`` class to provide their own resource authentication, based on the application's authentication choices.

    :param channel:
        Defines the Channel class which the resource will emit events into. Defaults to in-memory
    '''
    name = None
    object_class = None
    query = None
    sort = None
    hypermedia = True
    channel_class = Channel
    fts_operator = 'q'
    incoming_list = ['get', 'post', 'put', 'patch', 'delete']
    incoming_detail = ['get', 'post', 'put', 'patch', 'delete']
    outgoing_list = ['created', 'updated', 'deleted']
    outgoing_detail = ['created', 'updated', 'deleted']
    formatter = JSONFormatter()
    authentication = NoAuthentication()

    def __init__(self, meta=None):
        if meta:
            for attr in dir(meta):
                if not attr.startswith('_'):
                    setattr(self, attr, getattr(meta, attr))

class ResourceMeta(type):
    @classmethod
    def __prepare__(mcl, name, bases):
        ''' Adds the signal decorator so member methods can be decorated as signal receivers '''
        def receiver(signal):
            def _receiver(func):
                func._signal_receiver_ = signal

                @wraps(func)
                def wrapper(*args, **kwargs):
                    return func(*args, **kwargs)

                return wrapper
            return _receiver

        d = dict()
        d['receiver'] = receiver
        return d

    def __new__(mcl, name, bases, attrs):
        del attrs['receiver']
        cls = super(ResourceMeta, mcl).__new__(mcl, name, bases, attrs)

        # create default resource options
        options = ResourceOptions()
        # copy over resource options defined in base classes, if any
        for base in reversed(bases):
            if not hasattr(base, '_meta'):
                continue
            options.__dict__.update(base._meta.__dict__)

        # copy resource options defined in this resource, if any
        if hasattr(cls, 'Meta'):
            for attr in dir(cls.Meta):
                if not attr.startswith('_'):
                    setattr(options, attr, getattr(cls.Meta, attr))
        # create the combined resource options class
        cls._meta = ResourceOptions(options)
        return cls

class Resource(object, metaclass=ResourceMeta):
    '''
    Base class for all resources.
    '''
    responses = {
        'GET': OK,
        'POST': CREATED,
        'PUT': ACCEPTED,
        'PATCH': ACCEPTED,
        'DELETE': NO_CONTENT
    }
    status_map = {
        'list': OK,
        'detail': OK,
        'create': CREATED,
        'update': ACCEPTED,
        'delete': NO_CONTENT,
        'update_list': ACCEPTED,
        'create_detail': CREATED,
        'delete_list': NO_CONTENT
    }
    methods = {
        'list': {
            'GET': 'list',
            'POST': 'create',
            'PUT': 'update_list',
            'PATCH': 'modify_list',
            'DELETE': 'delete_list'
        },
        'detail': {
            'GET': 'detail',
            'POST': 'create_detail',
            'PUT': 'update',
            'PATCH': 'modify',
            'DELETE': 'delete'
        }
    }

    class Protocol(Enum):
        http = 10
        websocket = 20
        amqp = 30

    def __init__(self, *args, **kwargs):
        self.request = kwargs.get('request', None)
        self.endpoint = kwargs.get('endpoint', 'list')
        self.data = None

    def request_method(self):
        ''' Returns the HTTP method for the current request. '''
        return self.request.method.upper()

    def request_args(self):
        '''
        Returns the arguments passed with the request in a dictionary.
        Returns both URL resolved arguments and query string arguments.
        Implemented for specific http libraries in derived classes
        '''
        raise NotImplementedError()

    async def request_body(self):
        '''
        Returns the body of the current request.
        Implemented for specific http libraries in derived classes
        '''
        raise NotImplementedError()

    @classmethod
    def wrap_handler(cls, handler, protocol, **kwargs):
        ''' Wrap a request handler with the matching protocol handler '''
        def _wrapper(request, *args, **kwargs):
            instance = cls(request=request, **kwargs)
            if protocol == Resource.Protocol.http:
                return instance._wrap_http(handler, request=request, **kwargs)
            elif protocol == Resource.Protocol.websocket:
                return instance._wrap_ws(handler, request=request, **kwargs)
            elif protocol == Resource.Protocol.amqp:
                return instance._wrap_amqp(view_type, *args, **kwargs)
            else:
                raise Exception('Communication protocol not specified')
        return _wrapper

    @classmethod
    def as_view(cls, endpoint, protocol, *init_args, **init_kwargs):
        '''
        Used for hooking up the endpoints. Returns a wrapper function that creates
        a new instance of the resource class and calls the correct view method for it.
        '''
        def _wrapper(request, *args, **kwargs):
            ''' Make a new instance of the resource class and returns a coroutine for execution'''
            instance = cls(*init_args, endpoint=endpoint, request=request, **init_kwargs)
            if protocol == Resource.Protocol.http:
                return instance._wrap_http(cls.dispatch, endpoint=endpoint, *args, **kwargs)
            elif protocol == Resource.Protocol.websocket:
                return instance._wrap_ws(cls.dispatch, endpoint=endpoint, *args, **kwargs)
            elif protocol == Resource.Protocol.amqp:
                return instance._wrap_amqp(endpoint, *args, **kwargs)
            else:
                raise Exception('Communication protocol not specified')

        return _wrapper

    @classmethod
    def as_list(cls, protocol=Protocol.http, *args, **kwargs):
        ''' returns list views '''
        return cls.as_view('list', protocol, *args, **kwargs)

    @classmethod
    def as_detail(cls, protocol=Protocol.http, *args, **kwargs):
        ''' returns detail views '''
        return cls.as_view('detail', protocol, *args, **kwargs)

    async def _wrap_http(self, handler, *args, **kwargs):
        ''' wraps a handler with an HTTP request-response cycle'''
        try:
            method = self.request_method()
            # support preflight requests when CORS is enabled
            if method == 'OPTIONS':
                return self.build_http_response(None, status=NO_CONTENT)
            data = await handler(self, *args, **kwargs)
            # format the response object
            formatted = self.format(method, data)
            status = self.responses.get(method, OK)
            return self.build_http_response(formatted, status=status)
        except Exception as ex:
            return self.dispatch_error(ex)

    async def _wrap_ws(self, handler, *args, **kwargs):
        ''' wraps a handler by receiving a websocket request and returning a websocket response '''
        try:
            method = self.request_method()
            # call the wrapped handler
            data = await handler(self, *args, **kwargs)
            status = self.responses.get(method, OK)
            response = {
                'type': 'response',
                'key': getattr(self.request, 'key', None),
                'status': status,
                'payload': data
            }
        except Exception as ex:
            response = {
                'type': 'response',
                'key': getattr(self.request, 'key', None),
                'status': getattr(ex, 'status', 500),
                'payload': getattr(ex, 'msg', 'general error')
            }
        # return a formatted the response object
        return self.format(method, response)

    async def _wrap_amqp(self, handler, *args, **kwargs):
        return NotImplemented()

    @classmethod
    def nested_routes(cls, base_url, formatter: callable = None) -> list:
        '''
        Returns an array of ``Route`` objects which define additional routes on the resource.
        Implement in derived resources to add additional routes to the resource

        :param base_url:
            The URL prefix which will be prepended to all nested routes

        :param formatter:
            The format method to be used when parsing url variables.
            By default the resource's  ``route_param`` method is used, which formats the url based on which HTTP libary is used.

        '''
        return []

    def is_method_allowed(self, endpoint, method) -> bool:
        if endpoint == 'list':
            if method.lower() in self._meta.incoming_list:
                return True
        elif endpoint == 'detail':
            if method.lower() in self._meta.incoming_detail:
                return True
        return False

    async def dispatch(self, *args, **kwargs):
        '''
        This method handles the actual request to the resource.
        It performs all the neccesary checks and then executes the relevant member method which is mapped to the method name.
        Handles authentication and de-serialization before calling the required method.
        Handles the serialization of the response
        '''
        method = self.request_method()

        # get the db object associated with the app and assign to resource
        if hasattr(self.request.app, 'db'):
            setattr(self, 'db', self.request.app.db)

        # check if method is allowed
        if method not in self.methods.get(self.endpoint, {}):
            raise MethodNotImplemented("Unsupported method '{0}' for {1} endpoint.".format(method, self.endpoint))

        if self.is_method_allowed(self.endpoint, method) is False:
            raise MethodNotAllowed("Unsupported method '{0}' for {1} endpoint.".format(method, self.endpoint))

        # check user authentication
        if not await self._meta.authentication.is_authenticated(self.request):
            raise Unauthorized()

        # deserialize request data
        body = await self.request_body()
        self.data = self.parse(method, self.endpoint, body)
        if method != 'GET':
            self.data.update(kwargs)
        kwargs.update(self.request_args())
        view_method = getattr(self, self.methods[self.endpoint][method])
        # call request method
        data = await view_method(*args, **kwargs)
        # add hypermedia to the response, if response is not empty
        if data and self._meta.hypermedia is True:
            if self.endpoint == 'list' and method == 'GET':
                for item in data['objects']:
                    self.add_hypermedia(item)
            else:
                self.add_hypermedia(data)

        return data

    def dispatch_error(self, err):
        '''
        Handles the dispatch of errors
        '''
        try:
            data = {'error': [l for l in err.args]}
            body = self._meta.formatter.format(data)
        except Exception as ex:
            data = {'error': str(err)}
            body = self._meta.formatter.format(data)

        status = getattr(err, 'status', 500)
        return self.build_http_response(body, status=status)

    @classmethod
    def build_http_response(cls, data, status=200):
        '''
        Given some data, generates an HTTP response.
        If you're integrating with a new web framework, other than sanic or aiohttp, you **MUST**
        override this method within your subclass.

        :param data:
             The body of the response to send
        :type data:
            string
        :param status:
            (Optional) The status code to respond with. Default is ``200``
        :type status:
            integer
        :returns: A response object
        '''
        raise NotImplementedError()

    @classmethod
    def route_methods(cls):
        '''
        Returns the relevant representation of allowed HTTP methods for a given route.
        Implemented on the http library resource sub-class to match the requirements of the HTTP library
        '''
        raise NotImplementedError()

    @classmethod
    def route_param(cls, param, type=str):
        '''
        Returns the route representation of a url param, pertaining to the web library used.
        Implemented on the http library resource sub-class to match the requirements of the HTTP library
        '''
        raise NotImplementedError()

    def get_resource_uri(self):
        return '/{}/{}/'.format(getattr(self.__class__, 'api_name', None), getattr(self.__class__, 'resource_name', None))

    def parse(self, method, endpoint, body):
        ''' calls parse on list or detail '''
        if isinstance(body, dict):  # request body was already parsed
            return body
        if endpoint == 'list':
            return self.parse_list(body)

        return self.parse_detail(body)

    def parse_list(self, body):
        if body:
            return self._meta.formatter.parse(body)
        return []

    def parse_detail(self, body):
        if body:
            return self._meta.formatter.parse(body)
        return {}

    def add_hypermedia(self, obj):
        '''
        Adds HATEOAS links to the resource. Adds href link to self.
        Override in subclasses to include additional functionality
        '''
        if hasattr(self, 'pk'):
            obj['_links'] = {
                'self': {
                    'href': '{}{}/'.format(self.get_resource_uri(), obj[self.pk])
                }
            }

    def format(self, method, data):
        ''' Calls format on list or detail '''
        if data is None:
            if method == 'GET':
                raise NotFound()
            return ''

        return self._meta.formatter.format(data)

    def format_list(self, data):
        if data is None:
            return ''
        return self._meta.formatter.format(data)

    def format_detail(self, data):
        if data is None:
            return ''
        return self._meta.formatter.format(data)

    @classmethod
    def connect_signal_receivers(cls):
        # connect signal receivers
        for item in dir(cls):
            attr = getattr(cls, item)
            if hasattr(attr, '_signal_receiver_'):
                logger.debug('signal receiver subscription', attr)
                attr._signal_receiver_.connect(attr, sender=cls._meta.object_class)

    #  methods which derived classes should implement
    async def list(self, **kwargs):
        raise MethodNotImplemented()

    async def detail(self, **kwargs):
        raise MethodNotImplemented()

    async def create(self, **kwargs):
        raise MethodNotImplemented()

    async def update(self, **kwargs):
        raise MethodNotImplemented()

    async def modify(self, **kwargs):
        raise MethodNotImplemented()

    async def delete(self, **kwargs):
        raise MethodNotImplemented()

    async def update_list(self, **kwargs):
        raise MethodNotImplemented()

    async def modify_list(self, **kwargs):
        raise MethodNotImplemented()

    async def create_detail(self, **kwargs):
        raise MethodNotImplemented()

    async def delete_list(self, **kwargs):
        raise MethodNotImplemented()

class ModelResource(Resource):
    '''
    A specialized resource class for using data models. Requires further implementation for data persistency
    '''

    def __init__(self, *args, **kwargs):
        super(ModelResource, self).__init__(*args, **kwargs)
        # verify object class has a declared primary key
        if not hasattr(self._meta.object_class, 'primary_key') or not hasattr(self._meta.object_class, 'primary_key_type'):
            raise Exception('Cannot create a ModelResource to model {} without a primary key'.format(self._meta.object_class.__name__))
        self.pk = self._meta.object_class.primary_key
        self.pk_type = self._meta.object_class.primary_key_type
<|endoftext|>


<commit_before>import os
import asyncio
import logging
import discord
from discord.ext import commands
from cogs.utils.dataIO import dataIO
from .utils import checks

log = logging.getLogger('red.TempChannels')

class TempChannels:
    """
    allows creating temporary channels
    channels are auto-removed when empty
    requires server admin or manage channels to enable
    once enabled all users can use it
    """

    #todo: give the person who added the channel the Manage channel permission for that channel
    __author__ = "mikeshardmind"
    __version__ = "1.0"

    def __init__(self, bot):
        self.bot = bot
        self.settings = dataIO.load_json('data/tempchannels/settings.json')

    @checks.admin_or_permissions(Manage_channels=True)
    @commands.group(pass_context=True, no_pm=True)
    async def tempchanneltoggle(self, ctx):
        """toggles the temp channels commands on/off for all users
        this requires the "Manage Channels" permission
        """
        server = ctx.message.server
        if server.id not in self.settings:
            self.settings[server.id] = {'toggle': True,
                                        'channels': [],
                                        'cache': []
                                       }
            self.save_json()
            await self.bot.say('Creation of temporary channels is now enabled.')
        elif server.id in self.settings:
            if self.settings[server.id]['toggle'] is True:
                self.settings[server.id]['toggle'] = False
                self.save_json()
                await self.bot.say('Creation of temporary channels is now disabled.')
            else:
                self.settings[server.id]['toggle'] = True
                self.save_json()
                await self.bot.say('Creation of temporary channels is now enabled.')

    @commands.group(pass_context=True, no_pm=True)
    async def newtemp(self, ctx, name: str):
        """makes a new temporary channel
        channel name should be enclosed in quotation marks"""
        server = ctx.message.server
        perms = ctx.message.server.get_member(self.bot.user.id).server_permissions

        if perms.manage_channels is False:
            await self.bot.say('I do not have permission to do that')
        elif self.settings[server.id]['toggle'] is False:
            await self.bot.say('This command is currently turned off. Reenable with\n```[p]tempchanneltoggle```')
        else:
            channel = await self.bot.create_channel(server, name, type=discord.ChannelType.voice)
            self.settings[server.id]['channels'].append(channel.id)
            self.save_json()

    #Minimum permissions required to remove the channels forcefully is manage_channels
    @checks.admin_or_permissions(Manage_channels=True)
    @commands.group(pass_context=True, no_pm=True)
    async def purgetemps(self, ctx):
        """purges this server's temp channels even if in use"""
        server = ctx.message.server

        for channel_id in self.settings[server.id]['channels']:
            try:
                channel = server.get_channel(channel_id)
                await asyncio.sleep(0.25)
                await self.bot.delete_channel(channel)
                await self.bot.say('Temporary Channels Purged')
            except:
                e = sys.exc_info()[0]
                log.debug('Exception During purgetemps: ' % e)
                pass

            self.settings[server.id]['channels'].clear()
            self.save_json()

    def save_json(self):
        dataIO.save_json("data/tempchannels/settings.json", self.settings)

    async def autoempty(self, memb_before, memb_after):
        """This cog is Self Cleaning"""
        server = memb_after.server
        channels = self.settings[server.id]['channels']
        cache = self.settings[server.id]['cache']

        #this prevents channels from being deleted before being used
        #todo: track times and prevent permanently unused channels from lasting forever.
        if memb_after.voice.voice_channel is not None:
            channel = memb_after.voice.voice_channel
            if channel.id in channels:
                if channel.id not in cache:
                    cache.append(channel.id)
                    self.save_json()

        #check to see if any temp rooms are empty when someone leaves a chat room
        channel = memb_before.voice.voice_channel
        if channel.id in cache:
            if len(channel.voice_members) == 0:
                await self.bot.delete_channel(channel)
                cache.remove(channel.id)
                channels.remove(channel.id)
                self.save_json()

        #cleanup cache in cases I have yet to figure out why they occur rarely
        for channel_id in cache:
            if channel_id not in channels:
                cache.remove(channel_id)

def check_folder():
    f = 'data/tempchannels'
    if not os.path.exists(f):
        os.makedirs(f)

def check_file():
    f = 'data/tempchannels/settings.json'
    if dataIO.is_valid_json(f) is False:
        dataIO.save_json(f, {})

def setup(bot):
    check_folder()
    check_file()
    n = TempChannels(bot)
    bot.add_listener(n.autoempty, 'on_voice_state_update')
    bot.add_cog(n)
<commit_msg>I'm a dumbass, logging was broken for the most obvious of reasons<commit_after>import os
import sys
import asyncio
import logging
import discord
from discord.ext import commands
from cogs.utils.dataIO import dataIO
from .utils import checks

log = logging.getLogger('red.TempChannels')

class TempChannels:
    """
    allows creating temporary channels
    channels are auto-removed when empty
    requires server admin or manage channels to enable
    once enabled all users can use it
    """

    #todo: give the person who added the channel the Manage channel permission for that channel
    __author__ = "mikeshardmind"
    __version__ = "1.0"

    def __init__(self, bot):
        self.bot = bot
        self.settings = dataIO.load_json('data/tempchannels/settings.json')

    @checks.admin_or_permissions(Manage_channels=True)
    @commands.group(pass_context=True, no_pm=True)
    async def tempchanneltoggle(self, ctx):
        """toggles the temp channels commands on/off for all users
        this requires the "Manage Channels" permission
        """
        server = ctx.message.server
        if server.id not in self.settings:
            self.settings[server.id] = {'toggle': True,
                                        'channels': [],
                                        'cache': []
                                       }
            self.save_json()
            await self.bot.say('Creation of temporary channels is now enabled.')
        elif server.id in self.settings:
            if self.settings[server.id]['toggle'] is True:
                self.settings[server.id]['toggle'] = False
                self.save_json()
                await self.bot.say('Creation of temporary channels is now disabled.')
            else:
                self.settings[server.id]['toggle'] = True
                self.save_json()
                await self.bot.say('Creation of temporary channels is now enabled.')

    @commands.group(pass_context=True, no_pm=True)
    async def newtemp(self, ctx, name: str):
        """makes a new temporary channel
        channel name should be enclosed in quotation marks"""
        server = ctx.message.server
        perms = ctx.message.server.get_member(self.bot.user.id).server_permissions

        if perms.manage_channels is False:
            await self.bot.say('I do not have permission to do that')
        elif self.settings[server.id]['toggle'] is False:
            await self.bot.say('This command is currently turned off. Reenable with\n```[p]tempchanneltoggle```')
        else:
            channel = await self.bot.create_channel(server, name, type=discord.ChannelType.voice)
            self.settings[server.id]['channels'].append(channel.id)
            self.save_json()

    #Minimum permissions required to remove the channels forcefully is manage_channels
    @checks.admin_or_permissions(Manage_channels=True)
    @commands.group(pass_context=True, no_pm=True)
    async def purgetemps(self, ctx):
        """purges this server's temp channels even if in use"""
        server = ctx.message.server

        for channel_id in self.settings[server.id]['channels']:
            try:
                channel = server.get_channel(channel_id)
                await asyncio.sleep(0.25)
                await self.bot.delete_channel(channel)
                await self.bot.say('Temporary Channels Purged')
            except:
                e = sys.exc_info()[0]
                log.debug('Exception During purgetemps: ' +str(e))
                pass

            self.settings[server.id]['channels'].clear()
            self.save_json()

    def save_json(self):
        dataIO.save_json("data/tempchannels/settings.json", self.settings)

    async def autoempty(self, memb_before, memb_after):
        """This cog is Self Cleaning"""
        server = memb_after.server
        channels = self.settings[server.id]['channels']
        cache = self.settings[server.id]['cache']

        #this prevents channels from being deleted before being used
        #todo: track times and prevent permanently unused channels from lasting forever.
        if memb_after.voice.voice_channel is not None:
            channel = memb_after.voice.voice_channel
            if channel.id in channels:
                if channel.id not in cache:
                    cache.append(channel.id)
                    self.save_json()

        #check to see if any temp rooms are empty when someone leaves a chat room
        channel = memb_before.voice.voice_channel
        if channel.id in cache:
            if len(channel.voice_members) == 0:
                await self.bot.delete_channel(channel)
                cache.remove(channel.id)
                channels.remove(channel.id)
                self.save_json()

        #cleanup cache in cases I have yet to figure out why they occur rarely
        for channel_id in cache:
            if channel_id not in channels:
                cache.remove(channel_id)

def check_folder():
    f = 'data/tempchannels'
    if not os.path.exists(f):
        os.makedirs(f)

def check_file():
    f = 'data/tempchannels/settings.json'
    if dataIO.is_valid_json(f) is False:
        dataIO.save_json(f, {})

def setup(bot):
    check_folder()
    check_file()
    n = TempChannels(bot)
    bot.add_listener(n.autoempty, 'on_voice_state_update')
    bot.add_cog(n)
<|endoftext|>


<commit_before># -*- coding: utf-8 -*-

from __future__ import division

import multiprocessing

import numpy as np
import emcee
from george import GP

from .pca import PCA
from .util import atleast_2d_column

__all__ = 'MultiGP',

class _GPProcess(multiprocessing.Process):
    """
    Run a GP in a separate process.

    """
    def __init__(self, x, y, kernel, name=None):
        self._x = x
        self._y = y
        self._kernel = kernel
        self._in_pipe, self._out_pipe = multiprocessing.Pipe()
        super(_GPProcess, self).__init__(name=name)

    def run(self):
        gp = GP(self._kernel)
        gp.compute(self._x)
        y = self._y
        pipe = self._out_pipe

        for cmd, args, kwargs in iter(pipe.recv, None):
            if cmd == 'predict':
                result = gp.predict(y, *args, **kwargs)
                if len(result) == 2:
                    # only return the diagonal of the covariance matrix
                    result = result[0], result[1].diagonal()

            elif cmd == 'train':
                prior, nwalkers, nsteps, nburnsteps, verbose = args

                def log_post(pars):
                    log_prior = prior.logpdf(pars)
                    if not np.isfinite(log_prior):
                        return -np.inf
                    gp.kernel.pars = pars
                    return log_prior + gp.lnlikelihood(y, quiet=True)

                ndim = len(gp.kernel)
                sampler = emcee.EnsembleSampler(nwalkers, ndim, log_post)

                # sample random initial position from prior
                pos0 = prior.rvs(nwalkers)

                if verbose:
                    print(self.name, 'starting training burn-in')

                # run burn-in chain
                nburnsteps = nburnsteps or nsteps
                pos1 = sampler.run_mcmc(pos0, nburnsteps,
                                        storechain=verbose)[0]

                if verbose:
                    print(self.name, 'burn-in complete')
                    _print_sampler_stats(sampler)
                    print(self.name, 'starting production')

                # run production chain
                sampler.reset()
                sampler.run_mcmc(pos1, nsteps)

                if verbose:
                    print(self.name, 'training complete')
                    _print_sampler_stats(sampler)

                # set hyperparameters to max posterior point from chain
                gp.kernel.pars = sampler.flatchain[
                    sampler.lnprobability.argmax()
                ]

                # delete ref. to log_post()
                sampler.lnprobfn = None

                # return sampler and delete reference so memory will be cleared
                # at next command
                result = sampler
                del sampler

            else:
                result = ValueError('Unknown command: {}.'.format(cmd))

            pipe.send(result)

    def send_cmd(self, cmd, *args, **kwargs):
        self._in_pipe.send((cmd, args, kwargs))
        return self

    def stop(self):
        self._in_pipe.send(None)

    def get_result(self):
        res = self._in_pipe.recv()
        if isinstance(res, Exception):
            raise res
        return res

class MultiGP(object):
    """
    Multidimensional Gaussian process using principal component analysis.

    x: (nsamples, ndim)
        Design matrix.
    y: (nsamples, nfeatures)
        Model outputs.
    kernel: george kernel
        Template kernel for each GP.
    npc: number of principal components to use
        Passed directly to the PCA constructor.  A separate process will be
        created for each PC.

    """
    def __init__(self, x, y, kernel, npc=None):
        x = atleast_2d_column(x)
        self._x_min = x.min(axis=0)
        self._x_range = x.ptp(axis=0)
        x = self._standardize(x)
        self._ndim = x.shape[1]

        y = atleast_2d_column(y)
        if x.shape[0] != y.shape[0]:
            raise ValueError('x and y must have same number of samples.')

        self._pca = PCA(y, npc=npc)

        self._procs = tuple(
            _GPProcess(x, y, kernel, name='GP{}'.format(n))
            for n, y in enumerate(self._pca.transform().T)
        )
        for p in self._procs:
            p.start()

    def __len__(self):
        return self._pca.npc

    def __del__(self):
        try:
            for p in self._procs:
                p.stop()
        except AttributeError:
            pass

    def _standardize(self, x):
        """
        Scale x to the unit hypercube [0, 1]^ndim.

        """
        z = np.array(x, copy=True)
        z -= self._x_min
        z /= self._x_range

        return z

    def _destandardize(self, z):
        """
        Scale z back from the unit hypercube.

        """
        x = np.array(z, copy=True)
        x *= self._x_range
        x += self._x_min

        return x

    def train(self, prior, nwalkers, nsteps, nburnsteps=None, verbose=False):
        """
        Train the GPs, i.e. estimate the optimal hyperparameters via MCMC.

        prior: Prior object
            Priors for the kernel hyperparameters.
        nwalkers: number of MCMC walkers
        nsteps, nburnsteps: number of MCMC steps per walker
            nsteps must be specified, nburnsteps is optional.  If only
            nburnsteps is not given, both the burn-in and production chains
            will have nsteps; if nburnsteps is given, the burn-in chain will
            have nburnsteps and the production chain will have nsteps.
        verbose : boolean
            Whether to output status info.

        """
        for p in self._procs:
            p.send_cmd('train', prior, nwalkers, nsteps, nburnsteps, verbose)
        self._training_samplers = tuple(p.get_result() for p in self._procs)

    @property
    def training_samplers(self):
        """
        A tuple of the training samplers for each GP.

        """
        try:
            return self._training_samplers
        except AttributeError:
            raise RuntimeError('Training has not run yet.')

    def _predict_pc(self, t, mean_only=True, out=None):
        """
        Predict principal components at test points.

        t : (ntest, ndim)
            Test points.  Must already be standardized.
        mean_only : boolean, default True
            Whether to predict the GP mean only or also the variance.
        out : (ntest, npc), optional
            Array to write mean results into.

        """
        for p in self._procs:
            p.send_cmd('predict', t, mean_only=mean_only)

        results = [p.get_result() for p in self._procs]

        if out is None:
            out = np.empty((t.shape[0], len(self)))

        if mean_only:
            # results contains only the mean
            out.T[:] = results
            return out
        else:
            # results contains (mean, var) tuples -- unpack with zip
            out_var = np.empty_like(out)
            out.T[:], out_var.T[:] = zip(*results)
            return out, out_var

    def predict(self, t, mean_only=True):
        """
        Calculate predictions at test points.

        t : (ntest, ndim)
        mean_only : boolean, default True
            Whether to predict the GP mean only or also the variance.  The
            covariance is *not* computed between the test points t, only the
            variance of each output.

        """
        t = self._standardize(atleast_2d_column(t))
        z = self._predict_pc(t, mean_only=mean_only)

        if mean_only:
            return self._pca.inverse(z)
        else:
            return self._pca.inverse(z[0], var=z[1], y_cov=False)

    def calibrate(self, yexp, yerr, prior,
                  nwalkers, nsteps, nburnsteps=None,
                  verbose=False):
        """
        Calibrate GP input parameters to data.

        yexp: (nfeatures,)
            Experimental/calibration data.
        yerr: float
            Fractional error on experimental data.
        prior: Prior object
            Priors for input parameters.
        nwalkers: number of MCMC walkers
        nsteps, nburnsteps: number of MCMC steps per walker
            nsteps must be specified, nburnsteps is optional.  If only
            nburnsteps is not given, both the burn-in and production chains
            will have nsteps; if nburnsteps is given, the burn-in chain will
            have nburnsteps and the production chain will have nsteps.
        verbose : boolean
            Whether to output status info.

        """
        zexp = self._pca.transform(np.atleast_2d(yexp))
        zerrsq = np.square(yerr*zexp)

        def log_post(theta):
            log_prior = prior.logpdf(theta)
            if not np.isfinite(log_prior):
                return -np.inf, None
            zmodel = self._predict_pc(theta[np.newaxis, :])
            log_prob = -.5*np.sum(np.square(zmodel-zexp)/zerrsq)
            return log_prior + log_prob, zmodel

        sampler = emcee.EnsembleSampler(nwalkers, self._ndim, log_post)

        # sample random initial position from prior
        pos0 = prior.rvs(nwalkers)

        if verbose:
            print('starting calibration burn-in')

        # run burn-in chain
        nburnsteps = nburnsteps or nsteps
        pos1 = sampler.run_mcmc(pos0, nburnsteps, storechain=verbose)[0]

        if verbose:
            print('burn-in complete')
            _print_sampler_stats(sampler)
            print('starting production')

        # run production chain
        sampler.reset()
        sampler.run_mcmc(pos1, nsteps)

        if verbose:
            print('calibration complete')
            _print_sampler_stats(sampler)

        # delete ref. to log_post()
        sampler.lnprobfn = None

        self._cal_sampler = sampler

    @property
    def cal_sampler(self):
        """
        Calibration MCMC sampler object.  Note that cal_sampler.(flat)chain
        should not be accessed directly; instead, use the MultiGP properties
        cal_(flat)chain, which properly transform back to "normal" space.  Use
        MultiGP.cal_samples to access the calibration samples.

        """
        try:
            return self._cal_sampler
        except AttributeError:
            raise RuntimeError('Calibration has not run yet.')

    @property
    def cal_chain(self):
        """
        Calibration MCMC chain, shape (nwalkers, nsteps, ndim).

        """
        chain = self._destandardize(self.cal_sampler.chain)

        return chain

    @property
    def cal_flatchain(self):
        """
        Flat calibration MCMC chain, shape (nwalkers*nsteps, ndim).

        """
        flatchain = self._destandardize(self.cal_sampler.flatchain)

        return flatchain

    @property
    def cal_samples(self):
        """
        Posterior calibration samples, shape (nwalkers*nsteps, ndim).

        """
        pc_samples = np.reshape(self.cal_sampler.blobs, (-1, self._pca.npc))
        samples = self._pca.inverse(pc_samples)

        return samples

def _print_sampler_stats(sampler, fmt_str='{:.3g}'):
    """
    Output MCMC sampler statistics.

    """
    afrac = sampler.acceptance_fraction
    print('  acceptance fraction',
          ' ± '.join(fmt_str.format(i) for i in (afrac.mean(), afrac.std())))
    window = min(50, int(sampler.chain.shape[1]/2))
    acor = sampler.get_autocorr_time(window)
    print('  autocorrelation times',
          ' '.join(fmt_str.format(i) for i in acor))
<commit_msg>Check hyperparameters are positive in training.<commit_after># -*- coding: utf-8 -*-

from __future__ import division

import multiprocessing

import numpy as np
import emcee
from george import GP

from .pca import PCA
from .util import atleast_2d_column

__all__ = 'MultiGP',

class _GPProcess(multiprocessing.Process):
    """
    Run a GP in a separate process.

    """
    def __init__(self, x, y, kernel, name=None):
        self._x = x
        self._y = y
        self._kernel = kernel
        self._in_pipe, self._out_pipe = multiprocessing.Pipe()
        super(_GPProcess, self).__init__(name=name)

    def run(self):
        gp = GP(self._kernel)
        gp.compute(self._x)
        y = self._y
        pipe = self._out_pipe

        for cmd, args, kwargs in iter(pipe.recv, None):
            if cmd == 'predict':
                result = gp.predict(y, *args, **kwargs)
                if len(result) == 2:
                    # only return the diagonal of the covariance matrix
                    result = result[0], result[1].diagonal()

            elif cmd == 'train':
                prior, nwalkers, nsteps, nburnsteps, verbose = args

                def log_post(pars):
                    if np.any(pars < 0.):
                        return -np.inf
                    log_prior = prior.logpdf(pars)
                    if not np.isfinite(log_prior):
                        return -np.inf
                    gp.kernel.pars = pars
                    return log_prior + gp.lnlikelihood(y, quiet=True)

                ndim = len(gp.kernel)
                sampler = emcee.EnsembleSampler(nwalkers, ndim, log_post)

                # sample random initial position from prior
                pos0 = prior.rvs(nwalkers)

                if verbose:
                    print(self.name, 'starting training burn-in')

                # run burn-in chain
                nburnsteps = nburnsteps or nsteps
                pos1 = sampler.run_mcmc(pos0, nburnsteps,
                                        storechain=verbose)[0]

                if verbose:
                    print(self.name, 'burn-in complete')
                    _print_sampler_stats(sampler)
                    print(self.name, 'starting production')

                # run production chain
                sampler.reset()
                sampler.run_mcmc(pos1, nsteps)

                if verbose:
                    print(self.name, 'training complete')
                    _print_sampler_stats(sampler)

                # set hyperparameters to max posterior point from chain
                gp.kernel.pars = sampler.flatchain[
                    sampler.lnprobability.argmax()
                ]

                # delete ref. to log_post()
                sampler.lnprobfn = None

                # return sampler and delete reference so memory will be cleared
                # at next command
                result = sampler
                del sampler

            else:
                result = ValueError('Unknown command: {}.'.format(cmd))

            pipe.send(result)

    def send_cmd(self, cmd, *args, **kwargs):
        self._in_pipe.send((cmd, args, kwargs))
        return self

    def stop(self):
        self._in_pipe.send(None)

    def get_result(self):
        res = self._in_pipe.recv()
        if isinstance(res, Exception):
            raise res
        return res

class MultiGP(object):
    """
    Multidimensional Gaussian process using principal component analysis.

    x: (nsamples, ndim)
        Design matrix.
    y: (nsamples, nfeatures)
        Model outputs.
    kernel: george kernel
        Template kernel for each GP.
    npc: number of principal components to use
        Passed directly to the PCA constructor.  A separate process will be
        created for each PC.

    """
    def __init__(self, x, y, kernel, npc=None):
        x = atleast_2d_column(x)
        self._x_min = x.min(axis=0)
        self._x_range = x.ptp(axis=0)
        x = self._standardize(x)
        self._ndim = x.shape[1]

        y = atleast_2d_column(y)
        if x.shape[0] != y.shape[0]:
            raise ValueError('x and y must have same number of samples.')

        self._pca = PCA(y, npc=npc)

        self._procs = tuple(
            _GPProcess(x, y, kernel, name='GP{}'.format(n))
            for n, y in enumerate(self._pca.transform().T)
        )
        for p in self._procs:
            p.start()

    def __len__(self):
        return self._pca.npc

    def __del__(self):
        try:
            for p in self._procs:
                p.stop()
        except AttributeError:
            pass

    def _standardize(self, x):
        """
        Scale x to the unit hypercube [0, 1]^ndim.

        """
        z = np.array(x, copy=True)
        z -= self._x_min
        z /= self._x_range

        return z

    def _destandardize(self, z):
        """
        Scale z back from the unit hypercube.

        """
        x = np.array(z, copy=True)
        x *= self._x_range
        x += self._x_min

        return x

    def train(self, prior, nwalkers, nsteps, nburnsteps=None, verbose=False):
        """
        Train the GPs, i.e. estimate the optimal hyperparameters via MCMC.

        prior: Prior object
            Priors for the kernel hyperparameters.
        nwalkers: number of MCMC walkers
        nsteps, nburnsteps: number of MCMC steps per walker
            nsteps must be specified, nburnsteps is optional.  If only
            nburnsteps is not given, both the burn-in and production chains
            will have nsteps; if nburnsteps is given, the burn-in chain will
            have nburnsteps and the production chain will have nsteps.
        verbose : boolean
            Whether to output status info.

        """
        for p in self._procs:
            p.send_cmd('train', prior, nwalkers, nsteps, nburnsteps, verbose)
        self._training_samplers = tuple(p.get_result() for p in self._procs)

    @property
    def training_samplers(self):
        """
        A tuple of the training samplers for each GP.

        """
        try:
            return self._training_samplers
        except AttributeError:
            raise RuntimeError('Training has not run yet.')

    def _predict_pc(self, t, mean_only=True, out=None):
        """
        Predict principal components at test points.

        t : (ntest, ndim)
            Test points.  Must already be standardized.
        mean_only : boolean, default True
            Whether to predict the GP mean only or also the variance.
        out : (ntest, npc), optional
            Array to write mean results into.

        """
        for p in self._procs:
            p.send_cmd('predict', t, mean_only=mean_only)

        results = [p.get_result() for p in self._procs]

        if out is None:
            out = np.empty((t.shape[0], len(self)))

        if mean_only:
            # results contains only the mean
            out.T[:] = results
            return out
        else:
            # results contains (mean, var) tuples -- unpack with zip
            out_var = np.empty_like(out)
            out.T[:], out_var.T[:] = zip(*results)
            return out, out_var

    def predict(self, t, mean_only=True):
        """
        Calculate predictions at test points.

        t : (ntest, ndim)
        mean_only : boolean, default True
            Whether to predict the GP mean only or also the variance.  The
            covariance is *not* computed between the test points t, only the
            variance of each output.

        """
        t = self._standardize(atleast_2d_column(t))
        z = self._predict_pc(t, mean_only=mean_only)

        if mean_only:
            return self._pca.inverse(z)
        else:
            return self._pca.inverse(z[0], var=z[1], y_cov=False)

    def calibrate(self, yexp, yerr, prior,
                  nwalkers, nsteps, nburnsteps=None,
                  verbose=False):
        """
        Calibrate GP input parameters to data.

        yexp: (nfeatures,)
            Experimental/calibration data.
        yerr: float
            Fractional error on experimental data.
        prior: Prior object
            Priors for input parameters.
        nwalkers: number of MCMC walkers
        nsteps, nburnsteps: number of MCMC steps per walker
            nsteps must be specified, nburnsteps is optional.  If only
            nburnsteps is not given, both the burn-in and production chains
            will have nsteps; if nburnsteps is given, the burn-in chain will
            have nburnsteps and the production chain will have nsteps.
        verbose : boolean
            Whether to output status info.

        """
        zexp = self._pca.transform(np.atleast_2d(yexp))
        zerrsq = np.square(yerr*zexp)

        def log_post(theta):
            log_prior = prior.logpdf(theta)
            if not np.isfinite(log_prior):
                return -np.inf, None
            zmodel = self._predict_pc(theta[np.newaxis, :])
            log_prob = -.5*np.sum(np.square(zmodel-zexp)/zerrsq)
            return log_prior + log_prob, zmodel

        sampler = emcee.EnsembleSampler(nwalkers, self._ndim, log_post)

        # sample random initial position from prior
        pos0 = prior.rvs(nwalkers)

        if verbose:
            print('starting calibration burn-in')

        # run burn-in chain
        nburnsteps = nburnsteps or nsteps
        pos1 = sampler.run_mcmc(pos0, nburnsteps, storechain=verbose)[0]

        if verbose:
            print('burn-in complete')
            _print_sampler_stats(sampler)
            print('starting production')

        # run production chain
        sampler.reset()
        sampler.run_mcmc(pos1, nsteps)

        if verbose:
            print('calibration complete')
            _print_sampler_stats(sampler)

        # delete ref. to log_post()
        sampler.lnprobfn = None

        self._cal_sampler = sampler

    @property
    def cal_sampler(self):
        """
        Calibration MCMC sampler object.  Note that cal_sampler.(flat)chain
        should not be accessed directly; instead, use the MultiGP properties
        cal_(flat)chain, which properly transform back to "normal" space.  Use
        MultiGP.cal_samples to access the calibration samples.

        """
        try:
            return self._cal_sampler
        except AttributeError:
            raise RuntimeError('Calibration has not run yet.')

    @property
    def cal_chain(self):
        """
        Calibration MCMC chain, shape (nwalkers, nsteps, ndim).

        """
        chain = self._destandardize(self.cal_sampler.chain)

        return chain

    @property
    def cal_flatchain(self):
        """
        Flat calibration MCMC chain, shape (nwalkers*nsteps, ndim).

        """
        flatchain = self._destandardize(self.cal_sampler.flatchain)

        return flatchain

    @property
    def cal_samples(self):
        """
        Posterior calibration samples, shape (nwalkers*nsteps, ndim).

        """
        pc_samples = np.reshape(self.cal_sampler.blobs, (-1, self._pca.npc))
        samples = self._pca.inverse(pc_samples)

        return samples

def _print_sampler_stats(sampler, fmt_str='{:.3g}'):
    """
    Output MCMC sampler statistics.

    """
    afrac = sampler.acceptance_fraction
    print('  acceptance fraction',
          ' ± '.join(fmt_str.format(i) for i in (afrac.mean(), afrac.std())))
    window = min(50, int(sampler.chain.shape[1]/2))
    acor = sampler.get_autocorr_time(window)
    print('  autocorrelation times',
          ' '.join(fmt_str.format(i) for i in acor))
<|endoftext|>


<commit_before># Copyright 2013 OpenStack Foundation
# All Rights Reserved.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

"""Collection of utilities for parsing CLI clients output."""

import re

from tempest.openstack.common import log as logging

LOG = logging.getLogger(__name__)

delimiter_line = re.compile('^\+\-[\+\-]+\-\+$')

def details_multiple(output_lines, with_label=False):
    """Return list of dicts with item details from cli output tables.

    If with_label is True, key '__label' is added to each items dict.
    For more about 'label' see OutputParser.tables().
    """
    items = []
    tables_ = tables(output_lines)
    for table_ in tables_:
        if 'Property' not in table_['headers'] \
           or 'Value' not in table_['headers']:
            raise Exception('Invalid structure of table with details')
        item = {}
        for value in table_['values']:
            item[value[0]] = value[1]
        if with_label:
            item['__label'] = table_['label']
        items.append(item)
    return items

def details(output_lines, with_label=False):
    """Return dict with details of first item (table) found in output."""
    items = details_multiple(output_lines, with_label)
    return items[0]

def listing(output_lines):
    """Return list of dicts with basic item info parsed from cli output.
    """

    items = []
    table_ = table(output_lines)
    for row in table_['values']:
        item = {}
        for col_idx, col_key in enumerate(table_['headers']):
            item[col_key] = row[col_idx]
        items.append(item)
    return items

def tables(output_lines):
    """Find all ascii-tables in output and parse them.

    Return list of tables parsed from cli output as dicts.
    (see OutputParser.table())

    And, if found, label key (separated line preceding the table)
    is added to each tables dict.
    """
    tables_ = []

    table_ = []
    label = None

    start = False
    header = False

    if not isinstance(output_lines, list):
        output_lines = output_lines.split('\n')

    for line in output_lines:
        if delimiter_line.match(line):
            if not start:
                start = True
            elif not header:
                # we are after head area
                header = True
            else:
                # table ends here
                start = header = None
                table_.append(line)

                parsed = table(table_)
                parsed['label'] = label
                tables_.append(parsed)

                table_ = []
                label = None
                continue
        if start:
            table_.append(line)
        else:
            if label is None:
                label = line
            else:
                LOG.warn('Invalid line between tables: %s' % line)
    if len(table_) > 0:
        LOG.warn('Missing end of table')

    return tables_

def table(output_lines):
    """Parse single table from cli output.

    Return dict with list of column names in 'headers' key and
    rows in 'values' key.
    """
    table_ = {'headers': [], 'values': []}
    columns = None

    if not isinstance(output_lines, list):
        output_lines = output_lines.split('\n')

    if not output_lines[-1]:
        # skip last line if empty (just newline at the end)
        output_lines = output_lines[:-1]

    for line in output_lines:
        if delimiter_line.match(line):
            columns = _table_columns(line)
            continue
        if '|' not in line:
            LOG.warn('skipping invalid table line: %s' % line)
            continue
        row = []
        for col in columns:
            row.append(line[col[0]:col[1]].strip())
        if table_['headers']:
            table_['values'].append(row)
        else:
            table_['headers'] = row

    return table_

def _table_columns(first_table_row):
    """Find column ranges in output line.

    Return list of tuples (start,end) for each column
    detected by plus (+) characters in delimiter line.
    """
    positions = []
    start = 1  # there is '+' at 0
    while start < len(first_table_row):
        end = first_table_row.find('+', start)
        if end == -1:
            break
        positions.append((start, end))
        start = end + 1
    return positions
<commit_msg>Add a new exception for invalid structure<commit_after># Copyright 2013 OpenStack Foundation
# All Rights Reserved.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

"""Collection of utilities for parsing CLI clients output."""

import re

from tempest import exceptions
from tempest.openstack.common import log as logging

LOG = logging.getLogger(__name__)

delimiter_line = re.compile('^\+\-[\+\-]+\-\+$')

def details_multiple(output_lines, with_label=False):
    """Return list of dicts with item details from cli output tables.

    If with_label is True, key '__label' is added to each items dict.
    For more about 'label' see OutputParser.tables().
    """
    items = []
    tables_ = tables(output_lines)
    for table_ in tables_:
        if 'Property' not in table_['headers'] \
           or 'Value' not in table_['headers']:
            raise exceptions.InvalidStructure()
        item = {}
        for value in table_['values']:
            item[value[0]] = value[1]
        if with_label:
            item['__label'] = table_['label']
        items.append(item)
    return items

def details(output_lines, with_label=False):
    """Return dict with details of first item (table) found in output."""
    items = details_multiple(output_lines, with_label)
    return items[0]

def listing(output_lines):
    """Return list of dicts with basic item info parsed from cli output.
    """

    items = []
    table_ = table(output_lines)
    for row in table_['values']:
        item = {}
        for col_idx, col_key in enumerate(table_['headers']):
            item[col_key] = row[col_idx]
        items.append(item)
    return items

def tables(output_lines):
    """Find all ascii-tables in output and parse them.

    Return list of tables parsed from cli output as dicts.
    (see OutputParser.table())

    And, if found, label key (separated line preceding the table)
    is added to each tables dict.
    """
    tables_ = []

    table_ = []
    label = None

    start = False
    header = False

    if not isinstance(output_lines, list):
        output_lines = output_lines.split('\n')

    for line in output_lines:
        if delimiter_line.match(line):
            if not start:
                start = True
            elif not header:
                # we are after head area
                header = True
            else:
                # table ends here
                start = header = None
                table_.append(line)

                parsed = table(table_)
                parsed['label'] = label
                tables_.append(parsed)

                table_ = []
                label = None
                continue
        if start:
            table_.append(line)
        else:
            if label is None:
                label = line
            else:
                LOG.warn('Invalid line between tables: %s' % line)
    if len(table_) > 0:
        LOG.warn('Missing end of table')

    return tables_

def table(output_lines):
    """Parse single table from cli output.

    Return dict with list of column names in 'headers' key and
    rows in 'values' key.
    """
    table_ = {'headers': [], 'values': []}
    columns = None

    if not isinstance(output_lines, list):
        output_lines = output_lines.split('\n')

    if not output_lines[-1]:
        # skip last line if empty (just newline at the end)
        output_lines = output_lines[:-1]

    for line in output_lines:
        if delimiter_line.match(line):
            columns = _table_columns(line)
            continue
        if '|' not in line:
            LOG.warn('skipping invalid table line: %s' % line)
            continue
        row = []
        for col in columns:
            row.append(line[col[0]:col[1]].strip())
        if table_['headers']:
            table_['values'].append(row)
        else:
            table_['headers'] = row

    return table_

def _table_columns(first_table_row):
    """Find column ranges in output line.

    Return list of tuples (start,end) for each column
    detected by plus (+) characters in delimiter line.
    """
    positions = []
    start = 1  # there is '+' at 0
    while start < len(first_table_row):
        end = first_table_row.find('+', start)
        if end == -1:
            break
        positions.append((start, end))
        start = end + 1
    return positions
<|endoftext|>


<commit_before>from persistent.dict import PersistentDict
from zope.interface import implementer
from zope.component import adapter
from zope.annotation.interfaces import IAnnotations
from zope.i18n import translate
from zope.i18nmessageid import MessageFactory
from zope.publisher.interfaces.browser import IBrowserRequest
from BTrees.OOBTree import OOBTree
from Acquisition import aq_parent
from Products.CMFCore.utils import getToolByName
from plone.event.interfaces import IRecurrenceSupport
from plone.app.event.recurrence import Occurrence
from bda.plone.cart.interfaces import ICartItemDataProvider
from bda.plone.cart import (
    readcookie,
    extractitems,
    aggregate_cart_item_count,
    get_item_stock,
    CartItemStateBase,
)
from .interfaces import (
    IBuyableEvent,
    ISharedStock,
    ITicket,
    ITicketOccurrence,
    ISharedStockData,
    ITicketOccurrenceData,
)

_ = MessageFactory('bda.plone.ticketshop')

@implementer(ICartItemDataProvider)
@adapter(ITicketOccurrence)
def TicketOccurrenceCartItemDataProviderProxy(context):
    return ICartItemDataProvider(aq_parent(context))

@adapter(ISharedStock, IBrowserRequest)
class TicketCartItemState(CartItemStateBase):

    @property
    def aggregated_count(self):
        items = extractitems(readcookie(self.request))
        stock_data = ISharedStockData(self.context)
        aggregated_count = 0
        for uid in stock_data.related_uids:
            aggregated_count += aggregate_cart_item_count(uid, items)
        return aggregated_count

    @property
    def completely_exceeded_alert(self):
        message = _(u'alert_ticket_no_longer_available',
                    default=u'Ticket is no longer available, please '
                            u'remove from cart')
        return translate(message, context=self.request)

    def partly_exceeded_alert(self, exceed):
        message = _(u'alert_ticket_number_exceed',
                    default=u'Limit exceed by ${exceed} tickets',
                    mapping={'exceed': int(exceed)})
        return translate(message, context=self.request)

    def number_reservations_alert(self, reserved):
        message = _(u'alert_ticket_number_reserved',
                    default=u'${reserved} tickets reserved',
                    mapping={'reserved': int(reserved)})
        return translate(message, context=self.request)

    def alert(self, count):
        stock = get_item_stock(self.context)
        available = stock.available
        if available is None:
            return ''
        reserved = self.reserved
        exceed = self.exceed
        if not reserved and not exceed:
            return ''
        if exceed:
            remaining_available = self.remaining_available
            if remaining_available > 0:
                return self.partly_exceeded_alert(exceed)
            return self.completely_exceeded_alert
        if reserved:
            return self.number_reservations_alert(reserved)
        return ''

class CatalogMixin(object):

    @property
    def catalog(self):
        return getToolByName(self.context, 'portal_catalog')

SHARED_STOCK_DATA_KEY = 'bda.plone.ticketshop.shared_stock'

@implementer(ISharedStockData)
class SharedStockData(object):

    def __init__(self, context):
        self.context = context

    @property
    def shared_stock_context(self):
        raise NotImplementedError(u"Abstract ``SharedStockData`` does not "
                                  u"implement ``shared_stock_context``")

    @property
    def shared_stock_key(self):
        raise NotImplementedError(u"Abstract ``SharedStockData`` does not "
                                  u"implement ``shared_stock_key``")

    @property
    def related_uids(self):
        raise NotImplementedError(u"Abstract ``SharedStockData`` does not "
                                  u"implement ``related_uids``")

    @property
    def stock_data(self):
        annotations = IAnnotations(self.shared_stock_context)
        data = annotations.get(SHARED_STOCK_DATA_KEY, None)
        if data is None:
            data = OOBTree()
            annotations[SHARED_STOCK_DATA_KEY] = data
        return data

    def get(self, field_name):
        return self.stock_data.get(self.shared_stock_key, {}).get(field_name)

    def set(self, field_name, value):
        stock_data = self.stock_data
        data = stock_data.setdefault(self.shared_stock_key, PersistentDict())
        if not value:
            data[field_name] = None
        else:
            data[field_name] = float(value)

@adapter(ITicket)
class TicketSharedStock(SharedStockData, CatalogMixin):

    @property
    def shared_stock_context(self):
        return aq_parent(self.context)

    @property
    def shared_stock_key(self):
        return 'canonical_tickets'

    @property
    def related_uids(self):
        event = aq_parent(self.context)
        brains = self.catalog(**{
            'portal_type': 'Ticket',
            'path': '/'.join(event.getPhysicalPath()),
        })
        return [brain.UID for brain in brains]

@adapter(ITicketOccurrence)
class TicketOccurrenceSharedStock(SharedStockData, CatalogMixin):

    @property
    def shared_stock_context(self):
        return aq_parent(aq_parent(self.context))

    @property
    def shared_stock_key(self):
        return self.context.id

    @property
    def related_uids(self):
        event = aq_parent(aq_parent(self.context))
        brains = self.catalog(**{
            'portal_type': 'Ticket Occurrence',
            'path': '/'.join(event.getPhysicalPath()),
            'id': self.shared_stock_key,
        })
        return [brain.UID for brain in brains]

@implementer(ITicketOccurrenceData)
@adapter(IBuyableEvent)
class TicketOccurrenceData(CatalogMixin):

    def __init__(self, context):
        self.context = context

    @property
    def tickets(self):
        brains = self.catalog(**{
            'portal_type': 'Ticket',
            'path': '/'.join(self.context.getPhysicalPath()),
        })
        return [brain.getObject() for brain in brains]

    def ticket_occurrences(self, occurrence_id):
        brains = self.catalog(**{
            'id': occurrence_id,
            'portal_type': 'Ticket Occurrence',
            'path': '/'.join(self.context.getPhysicalPath()),
        })
        return [brain.getObject() for brain in brains]

    def _copy_field_value(self, ticket, ticket_occurrence, field_name):
        ticket_field = ticket.getField(field_name)
        value = ticket_field.getAccessor(ticket)()
        ticket_occurrence_field = ticket_occurrence.getField(field_name)
        mutator = ticket_occurrence_field.getMutator(ticket_occurrence)
        mutator(value)

    def create_ticket_occurrences(self):
        tickets = self.tickets
        recurrence = IRecurrenceSupport(self.context)
        for occurrence in recurrence.occurrences():
            if not isinstance(occurrence, Occurrence):
                continue
            for ticket in tickets:
                if occurrence.id in ticket.objectIds():
                    continue
                ticket.invokeFactory(
                    'Ticket Occurrence',
                    occurrence.id,
                    title=ticket.Title())
                ticket_occurrence = ticket[occurrence.id]
                self._copy_field_value(
                    ticket, ticket_occurrence, 'item_available')
                self._copy_field_value(
                    ticket, ticket_occurrence, 'item_overbook')
                ticket_occurrence.reindexObject()
<commit_msg>avoid error on contexts without annotations<commit_after>from persistent.dict import PersistentDict
from zope.interface import implementer
from zope.component import adapter
from zope.annotation.interfaces import IAnnotations
from zope.i18n import translate
from zope.i18nmessageid import MessageFactory
from zope.publisher.interfaces.browser import IBrowserRequest
from BTrees.OOBTree import OOBTree
from Acquisition import aq_parent
from Products.CMFCore.utils import getToolByName
from plone.event.interfaces import IRecurrenceSupport
from plone.app.event.recurrence import Occurrence
from bda.plone.cart.interfaces import ICartItemDataProvider
from bda.plone.cart import (
    readcookie,
    extractitems,
    aggregate_cart_item_count,
    get_item_stock,
    CartItemStateBase,
)
from .interfaces import (
    IBuyableEvent,
    ISharedStock,
    ITicket,
    ITicketOccurrence,
    ISharedStockData,
    ITicketOccurrenceData,
)

_ = MessageFactory('bda.plone.ticketshop')

@implementer(ICartItemDataProvider)
@adapter(ITicketOccurrence)
def TicketOccurrenceCartItemDataProviderProxy(context):
    return ICartItemDataProvider(aq_parent(context))

@adapter(ISharedStock, IBrowserRequest)
class TicketCartItemState(CartItemStateBase):

    @property
    def aggregated_count(self):
        items = extractitems(readcookie(self.request))
        stock_data = ISharedStockData(self.context)
        aggregated_count = 0
        for uid in stock_data.related_uids:
            aggregated_count += aggregate_cart_item_count(uid, items)
        return aggregated_count

    @property
    def completely_exceeded_alert(self):
        message = _(u'alert_ticket_no_longer_available',
                    default=u'Ticket is no longer available, please '
                            u'remove from cart')
        return translate(message, context=self.request)

    def partly_exceeded_alert(self, exceed):
        message = _(u'alert_ticket_number_exceed',
                    default=u'Limit exceed by ${exceed} tickets',
                    mapping={'exceed': int(exceed)})
        return translate(message, context=self.request)

    def number_reservations_alert(self, reserved):
        message = _(u'alert_ticket_number_reserved',
                    default=u'${reserved} tickets reserved',
                    mapping={'reserved': int(reserved)})
        return translate(message, context=self.request)

    def alert(self, count):
        stock = get_item_stock(self.context)
        available = stock.available
        if available is None:
            return ''
        reserved = self.reserved
        exceed = self.exceed
        if not reserved and not exceed:
            return ''
        if exceed:
            remaining_available = self.remaining_available
            if remaining_available > 0:
                return self.partly_exceeded_alert(exceed)
            return self.completely_exceeded_alert
        if reserved:
            return self.number_reservations_alert(reserved)
        return ''

class CatalogMixin(object):

    @property
    def catalog(self):
        return getToolByName(self.context, 'portal_catalog')

SHARED_STOCK_DATA_KEY = 'bda.plone.ticketshop.shared_stock'

@implementer(ISharedStockData)
class SharedStockData(object):

    def __init__(self, context):
        self.context = context

    @property
    def shared_stock_context(self):
        raise NotImplementedError(u"Abstract ``SharedStockData`` does not "
                                  u"implement ``shared_stock_context``")

    @property
    def shared_stock_key(self):
        raise NotImplementedError(u"Abstract ``SharedStockData`` does not "
                                  u"implement ``shared_stock_key``")

    @property
    def related_uids(self):
        raise NotImplementedError(u"Abstract ``SharedStockData`` does not "
                                  u"implement ``related_uids``")

    @property
    def stock_data(self):
        annotations = IAnnotations(self.shared_stock_context, None)
        data = annotations and annotations.get(SHARED_STOCK_DATA_KEY, None)\
                or None
        if data is None:
            data = OOBTree()
            annotations[SHARED_STOCK_DATA_KEY] = data
        return data

    def get(self, field_name):
        return self.stock_data.get(self.shared_stock_key, {}).get(field_name)

    def set(self, field_name, value):
        stock_data = self.stock_data
        data = stock_data.setdefault(self.shared_stock_key, PersistentDict())
        if not value:
            data[field_name] = None
        else:
            data[field_name] = float(value)

@adapter(ITicket)
class TicketSharedStock(SharedStockData, CatalogMixin):

    @property
    def shared_stock_context(self):
        return aq_parent(self.context)

    @property
    def shared_stock_key(self):
        return 'canonical_tickets'

    @property
    def related_uids(self):
        event = aq_parent(self.context)
        brains = self.catalog(**{
            'portal_type': 'Ticket',
            'path': '/'.join(event.getPhysicalPath()),
        })
        return [brain.UID for brain in brains]

@adapter(ITicketOccurrence)
class TicketOccurrenceSharedStock(SharedStockData, CatalogMixin):

    @property
    def shared_stock_context(self):
        return aq_parent(aq_parent(self.context))

    @property
    def shared_stock_key(self):
        return self.context.id

    @property
    def related_uids(self):
        event = aq_parent(aq_parent(self.context))
        brains = self.catalog(**{
            'portal_type': 'Ticket Occurrence',
            'path': '/'.join(event.getPhysicalPath()),
            'id': self.shared_stock_key,
        })
        return [brain.UID for brain in brains]

@implementer(ITicketOccurrenceData)
@adapter(IBuyableEvent)
class TicketOccurrenceData(CatalogMixin):

    def __init__(self, context):
        self.context = context

    @property
    def tickets(self):
        brains = self.catalog(**{
            'portal_type': 'Ticket',
            'path': '/'.join(self.context.getPhysicalPath()),
        })
        return [brain.getObject() for brain in brains]

    def ticket_occurrences(self, occurrence_id):
        brains = self.catalog(**{
            'id': occurrence_id,
            'portal_type': 'Ticket Occurrence',
            'path': '/'.join(self.context.getPhysicalPath()),
        })
        return [brain.getObject() for brain in brains]

    def _copy_field_value(self, ticket, ticket_occurrence, field_name):
        ticket_field = ticket.getField(field_name)
        value = ticket_field.getAccessor(ticket)()
        ticket_occurrence_field = ticket_occurrence.getField(field_name)
        mutator = ticket_occurrence_field.getMutator(ticket_occurrence)
        mutator(value)

    def create_ticket_occurrences(self):
        tickets = self.tickets
        recurrence = IRecurrenceSupport(self.context)
        for occurrence in recurrence.occurrences():
            if not isinstance(occurrence, Occurrence):
                continue
            for ticket in tickets:
                if occurrence.id in ticket.objectIds():
                    continue
                ticket.invokeFactory(
                    'Ticket Occurrence',
                    occurrence.id,
                    title=ticket.Title())
                ticket_occurrence = ticket[occurrence.id]
                self._copy_field_value(
                    ticket, ticket_occurrence, 'item_available')
                self._copy_field_value(
                    ticket, ticket_occurrence, 'item_overbook')
                ticket_occurrence.reindexObject()
<|endoftext|>