class ReadOnly(permissions.BasePermission):
    """
    Allow only read actions
    """
    message = "You can only read this data"

    def has_permission(self, request, view) -> bool:
        if not view.action:
            raise MethodNotAllowed(request.method)
        return request.method in permissions.SAFE_METHODS OBJECT_ACTIONS = {'retrieve', 'update', 'partial_update', 'destroy'} class InvalidRequest(APIException):
    status_code = status.HTTP_400_BAD_REQUEST
    default_detail = "Votre requête n'est pas valide"
    default_code = 'invalid_request' class OAuthAPI:
    """
    Accès à l'API du portail des assos ou autre API OAuth2
    Utile pour la connexion, la récupération des droits
    """

    def __init__(self, provider: str='portal', config: dict=None, token: dict=None, session=None):
        """
        OAuth2 Client initialisation
        """
        self.provider = provider
        self.config = config or settings.OAUTH[provider].copy()
        if token:
            self.config['token'] = token
        elif session:
            self.config['token'] = session.get(OAUTH_TOKEN_NAME)
        self.client = OAuth2Session(**self.config)

    def __del__(self):
        """
        Close OAuth2 Client
        """
        self.client.close()

    def get_auth_url(self, redirection: str) -> str:
        """
        Return authorization url
        """
        # Get url and state from OAuth server
        url, state = self.client.create_authorization_url(self.config['authorize_url'])

        # Cache front url with state for 5mins
        cache.set(state, redirection, 300)
        return url

    def callback_and_create_session(self, request) -> str:
        """
        Get token, user informations, store these and redirect
        """
        state = request.GET.get('state')
        error = request.GET.get('error')
        code = request.GET.get('code')

        if not state:
            raise OAuthException(
                message="Réponse OAuth incorrecte",
                code="invalid_oauth_response",
                details="No state returned"
            )

        if error or not code:
            message = None
            if error == 'access_denied':
                message = "L'accès aux données a été refusé"

            redirection = cache.get(state)
            cache.delete(state)
            if redirection:
                return redirection

            raise OAuthTokenException(
                message=message,
                code="oauth_callback_error",
                details=error,
            )

        try:
            # Get token from code
            token = self.client.fetch_access_token(self.config['access_token_url'], code=code)
        except AuthlibBaseError as error:
            raise OAuthException(
                message="Impossible de récuperer le Token OAuth",
                code="fetch_access_token_error",
                details=str(error)
            ) from error

        # Fetch and login user into Django, then create session
        user = self.fetch_user()
        request.user = user
        django_auth.login(request, user)
        request.session['user_id'] = str(user.id)
        request.session[OAUTH_TOKEN_NAME] = token

        # Get front redirection from cached state
        redirection = cache.get(state)
        cache.delete(state)
        return redirection or 'root'

    def logout(self, request, redirection: str=None) -> str:
        """
        Logout the user from Woolly and redirect to the provider's logout
        """
        # TODO Revoke user token ??? POSSIBLE
        # token = request.session.get(OAUTH_TOKEN_NAME)
        # if token:
        #   self.client.revoke_token(url, token)

        # Logout from Django
        request.user = None
        django_auth.logout(request)

        # Redirect to logout
        return self.config['logout_url']

    def fetch_resource(self, query: str) -> Union[dict, list]:
        """
        Return data from the API if valid else raise an OAuthException
        """
        if self.client.token:
            try:
                resp = self.client.get(self.config['base_url'] + query)
            except AuthlibBaseError as error:
                code = getattr(error, 'error', None)
                raise OAuthTokenException(code=code) from error
        else:
            # Try vanilla request if no token is specified
            try:
                resp = requests.get(self.config['base_url'] + query)
                if not resp.ok:
                    resp.raise_for_status()
            except requests.RequestException as error:
                raise OAuthTokenException(
                    message="Erreur lors de la requête, essayez avec un token valide.",
                    code="vanilla_request_error",
                ) from error

        if resp.ok:
            return resp.json()

        raise OAuthException.from_response(resp)

    def fetch_user(self, user_id: str=None) -> UserModel:
        """
        Get specified or current user
        """
        # Get and patch data from API
        url = (f"users/{user_id}" if user_id else "user") + "/?types=*"
        data = self.fetch_resource(url)
        if hasattr(UserModel, 'patch_fetched_data'):
            data = UserModel.patch_fetched_data(data)

        # Get or create user from db
        new_user = False
        try:
            user = UserModel.objects.get(pk=data['id'])
        except UserModel.DoesNotExist:
            fields_data = filter_dict_keys(data, UserModel.field_names())
            user = UserModel(**fields_data)
            new_user = True

        # Update with fetched data and return
        updated_fields = user.sync_data(data, save=False)
        if updated_fields or new_user:
            user.save()

        return user class Association(APIModel):
    """
    Defines an Association
    """
    id = models.UUIDField(primary_key=True, editable=False)
    shortname = models.CharField(max_length=NAME_FIELD_MAXLEN)
    fun_id    = models.PositiveSmallIntegerField(null=True, blank=True)
    # TODO Abstraire payment

    @classmethod
    def get_api_endpoint(cls, params: dict) -> str:
        url = 'assos'
        if 'pk' in params:
            url += cls.pk_to_url(params['pk'])

        # TODO Ask for specific roles in associations
        if 'user_pk' in params:
            # Don't filter association if user is admin
            user_is_admin = User.objects.values_list('is_admin', flat=True) \
                                        .get(pk=params['user_pk'])
            if not user_is_admin:
                url = f"users/{params['user_pk']}/{url}"
        return url

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

    class Meta:
        ordering = ('shortname',) class Sale(models.Model):
    """
    Defines a Sale
    """
    # Description
    id          = models.SlugField(primary_key=True, max_length=64, blank=False)
    name        = models.CharField(max_length=NAME_FIELD_MAXLEN)
    description = models.CharField(max_length=DESC_FIELD_MAXLEN, blank=True)
    association = models.ForeignKey(Association, on_delete=models.DO_NOTHING, related_name='sales')

    # Visibility
    is_active   = models.BooleanField(default=True)
    is_public   = models.BooleanField(default=True)

    # Timestamps
    created_at  = models.DateTimeField(auto_now_add=True, editable=False)
    begin_at    = models.DateTimeField()
    end_at      = models.DateTimeField()

    max_item_quantity = models.PositiveIntegerField(blank=True, null=True)

    # Customization
    cgv   = models.URLField(max_length=URL_FIELD_MAXLEN, blank=True, null=True)
    image = models.URLField(max_length=URL_FIELD_MAXLEN, blank=True, null=True)
    color = models.CharField(max_length=6, blank=True, null=True)

    # TODO mail_template tickets
    # TODO paymentmethods = models.ManyToManyField(PaymentMethod)

    def __str__(self) -> str:
        return f"{self.name} par {self.association}"

    class Meta:
        ordering = ('-begin_at',)
        indexes = [
            models.Index(fields=('-created_at',)),
        ] class Item(models.Model):
    """
    Defines a sellable Item
    """
    # Description
    name        = models.CharField(max_length=NAME_FIELD_MAXLEN)
    description = models.CharField(max_length=DESC_FIELD_MAXLEN, blank=True)
    sale        = models.ForeignKey(Sale,
                                    on_delete=models.CASCADE,
                                    related_name='items')
    group       = models.ForeignKey(ItemGroup,
                                    blank=True, null=True, default=None,
                                    on_delete=models.SET_NULL, related_name='items')

    # Specifications
    usertype     = models.ForeignKey(UserType, on_delete=models.PROTECT)
    quantity     = models.PositiveSmallIntegerField(blank=True, null=True)
    max_per_user = models.PositiveSmallIntegerField(blank=True, null=True)
    is_active    = models.BooleanField(default=True)
    price        = models.FloatField()

    # Payment
    nemopay_id   = models.CharField(max_length=30, blank=True, null=True)
    # TODO Abstraire payment

    fields = models.ManyToManyField('Field',
                                    through='ItemField',
                                    through_fields=('item', 'field'))

    def quantity_sold(self) -> int:
        """
        Count item quantity sold
        """
        filters = {
            'sale': self.sale_id,
            'orderlines__item__pk': self.pk,
            'status__in': OrderStatus.BOOKING_LIST.value,
        }

        qt_sum = models.Sum('orderlines__quantity',
                            filter=models.Q(orderlines__item__pk=self.pk))
        return (
            Order.objects.filter(**filters)
            .annotate(orderline_quantity=qt_sum)
            .aggregate(sum=models.Sum('orderline_quantity'))
        )['sum'] or 0

    def quantity_left(self) -> int:
        """
        Get quantity left
        """
        if self.quantity is None:
            return None
        return self.quantity - self.quantity_sold()

    def quantity_estimation(self) -> int:
        """
        Quantity estimation for client UI x/3
        """
        if self.quantity is None:
            return None
        percent = self.quantity_sold() / self.quantity
        if percent > 0.45:
            return 3
        elif percent > 0.2:
            return 2
        elif percent > 0:
            return 1
        else:
            return 0

    def save(self, *args, **kwargs) -> None:
        """
        Save item and synch it with the payment system
        """
        from payment.helpers import get_pay_service
        pay_service = get_pay_service(self)
        try:
            # FIXME Better error management
            pay_service.sync_item(self)
        finally:
            super().save(*args, **kwargs)

    def __str__(self) -> str:
        return f"{self.name} ({self.sale})"

    class Meta:
        ordering = ('id',) class ItemGroup(models.Model):
    """
    Gathers items under a common group
    for better display or validation
    """
    name         = models.CharField(max_length=NAME_FIELD_MAXLEN)
    description  = models.CharField(max_length=DESC_FIELD_MAXLEN, blank=True)
    sale         = models.ForeignKey(Sale, on_delete=models.CASCADE, related_name='itemgroups')

    is_active    = models.BooleanField(default=True)
    quantity     = models.PositiveIntegerField(blank=True, null=True)
    max_per_user = models.PositiveIntegerField(blank=True, null=True)

    def __str__(self) -> str:
        return self.name class ItemField(models.Model):
    """
    Links an Item to a Field with additionnal options
    """
    field = models.ForeignKey(Field,
                              on_delete=models.CASCADE,
                              related_name='itemfields',
                              editable=False)
    item  = models.ForeignKey(Item,
                              on_delete=models.CASCADE,
                              related_name='itemfields',
                              editable=False)
    editable = models.BooleanField(default=True)

    def __str__(self) -> str:
        return f"{self.item} - {self.field}"

    class Meta:
        ordering = ('id',) class Order(models.Model):
    """
    Defines the Order object
    This is the central model of all the project
    """
    owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='orders', editable=False)
    sale  = models.ForeignKey(Sale, on_delete=models.CASCADE, related_name='orders', editable=False)

    created_at = models.DateTimeField(auto_now_add=True, editable=False)
    updated_at = models.DateTimeField(auto_now=True)

    status = models.PositiveSmallIntegerField(
        choices=OrderStatus.choices(),
        default=OrderStatus.ONGOING.value,
    )
    # TODO Abstraire payment
    tra_id = models.IntegerField(blank=True, null=True, default=None)

    # ----- Additional methods

    def is_expired(self) -> bool:
        """
        Check expiracy time according to order status and expire if needed
        """
        if self.status == OrderStatus.EXPIRED.value:
            return True

        if self.status in OrderStatus.STABLE_LIST.value:
            return False

        delta = timezone.now() - self.created_at
        return any((
            self.status == OrderStatus.ONGOING.value and delta > settings.MAX_ONGOING_TIME,
            self.status == OrderStatus.AWAITING_PAYMENT.value and delta > settings.MAX_PAYMENT_TIME,
            self.status == OrderStatus.AWAITING_VALIDATION.value and delta > settings.MAX_VALIDATION_TIME,
        ))

    def fetch_status(self) -> OrderStatus:
        """
        Fetch the status from external APIs if needed, doesn't update it !
        """
        if self.status in OrderStatus.STABLE_LIST.value:
            return OrderStatus(self.status)

        # Check payment status if waiting
        if self.status == OrderStatus.AWAITING_PAYMENT.value:
            from payment.helpers import get_pay_service
            return get_pay_service(self).get_transaction_status(self)

        if self.is_expired():
            return OrderStatus.EXPIRED

        return OrderStatus(self.status)

    def update_status(self, status: OrderStatus=None) -> dict:
        """
        Update the order status, make side changes if needed,
        and return an update response
        """
        if status is None:
            status = self.fetch_status()

        resp = {
            'old_status': self.get_status_display(),
            # Do not update if same or stable status
            'updated': self.status != status.value and self.status not in OrderStatus.STABLE_LIST.value,
            # Redirect to payment if needed
            'redirect_to_payment': status and status.value == OrderStatus.AWAITING_PAYMENT.value,
            # If sale freshly validated, generate tickets
            'tickets_generated': (status
                                  and self.status not in OrderStatus.VALIDATED_LIST.value
                                  and status.value in OrderStatus.VALIDATED_LIST.value),
        }

        # Update order status
        if resp['updated']:
            self.status = status.value
            self.save()

        # Generate tickets if needed
        if resp['tickets_generated']:
            self.generate_orderlineitems_and_fields()

        resp['status']  = self.get_status_display()
        resp['message'] = OrderStatus.MESSAGES.value[self.status]
        return resp

    @transaction.atomic
    def generate_orderlineitems_and_fields(self) -> int:
        """
        When an order has just been validated, create
        all the orderlineitems and fields required
        TODO : add a lock or something, and improve from scripts
        """
        from .serializers import OrderLineItemSerializer, OrderLineFieldSerializer
        orderlines = self.orderlines.filter(quantity__gt=0) \
                         .prefetch_related('item', 'orderlineitems')

        total = 0
        for orderline in orderlines.all():
            qte = orderline.quantity - len(orderline.orderlineitems.all())
            while qte > 0:
                # Create OrderLineItem
                orderlineitem = OrderLineItemSerializer(data={
                    'orderline': orderline.id
                })
                orderlineitem.is_valid(raise_exception=True)
                orderlineitem.save()

                # Create OrderLineFields
                for field in orderline.item.fields.all():
                    orderlinefield = OrderLineFieldSerializer(data = {
                        'orderlineitem': orderlineitem.data['id'],
                        'field': field.id,
                        'value': get_field_default_value(field.default, self),
                    })
                    orderlinefield.is_valid(raise_exception=True)
                    orderlinefield.save()

                total += 1
                qte -= 1

        return total

    def send_confirmation_mail(self):
        """
        Send a confirmation mail to the owner of the order
        """
        if self.status not in OrderStatus.VALIDATED_LIST.value:
            raise Exception(f"The order must be valid to send a confirmation mail")

        # TODO : généraliser + markdown
        link_order = f"http://assos.utc.fr/woolly/commandes/{self.pk}"
        order_list = "".join(f" - {ol.quantity} {ol.item.name}\n" for ol in self.orderlines.all())
        message = (
            "Bonjour {self.owner.get_full_name()},\n\n"
            f"Votre commande n°{self.pk} vient d'être confirmée.\n"
            f"Vous avez commandé:\n{order_list}"
            f"Vous pouvez télécharger vos billets ici : {link_order}\n\n"
            "Merci d'avoir utilisé Woolly"
        )

        email = EmailMessage(
            subject="Woolly - Confirmation de commande",
            body=message,
            from_email="woolly@assos.utc.fr",
            to=[self.owner.email],
            reply_to=["woolly@assos.utc.fr"],
        )
        return email.send()

    def __str__(self) -> str:
        return f"N°{self.id} [{self.get_status_display()}] by {self.owner}"

    class Meta:
        ordering = ('-id',) class OrderLine(models.Model):
    """
    Links an Order to an Item with a quantity
    """
    item     = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='orderlines', editable=False)
    order    = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='orderlines', editable=False)
    quantity = models.PositiveSmallIntegerField()

    def __str__(self) -> str:
        return f"{self.id} - {self.quantity} x {self.item.name} (Order {self.order})"

    class Meta:
        ordering = ('id',) class OrderLineItem(models.Model):
    """
    Represents a single OrderLine.item with a unique uuid for ticketing
    May have specifications with related OrderLineFields
    """
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    orderline = models.ForeignKey(OrderLine, on_delete=models.CASCADE, related_name="orderlineitems")

    def __str__(self) -> str:
        return f"{self.id} - {self.orderline}"

    class Meta:
        ordering = ('id',) class OrderLineField(models.Model):
    """
    Specifies the Field value taken by the OrderLine Item
    """
    orderlineitem = models.ForeignKey(OrderLineItem,
                                      on_delete=models.CASCADE,
                                      related_name='orderlinefields')
    field = models.ForeignKey(Field,
                              on_delete=models.CASCADE,
                              related_name='orderlinefields')
    value = models.CharField(max_length=512, blank=True, null=True, editable='is_editable')

    def is_editable(self) -> bool:
        itemfield = ItemField.objects.get(
            field__pk=self.field.pk,
            item__pk=self.orderlineitem.orderline.item.pk
        )
        return itemfield.editable

    def __str__(self) -> str:
        return f"{self.orderlineitem.id} - {self.field.name} = {self.value}"

    class Meta:
        ordering = ('id',)
from typing import Any
from rest_framework import permissions
from rest_framework.exceptions import MethodNotAllowed
from core.permissions import ReadOnly, OBJECT_ACTIONS
from core.exceptions import InvalidRequest
from authentication.oauth import OAuthAPI
from .models import (
    Association, Sale, Item, ItemGroup, ItemField,
    Order, OrderLine, OrderLineItem, OrderLineField
)
    try:
        query = Model.objects
        if related:
            query = query.select_related(*related)
        return query.get(pk=pk)
    except Model.DoesNotExist:
        raise InvalidRequest(f"Could not retrieve related {Model.__name__}",
                             code='not_found_related_model')


def get_related_asso_id(request, view) -> str:

    if view.action is None:
        raise MethodNotAllowed(request.method)

    Model = view.queryset.model
    hasPk = 'pk' in view.kwargs
    pk = view.kwargs.get('pk')

    if Model is Association:
        return pk

    elif Model is Sale:
        if hasPk:
            return get_related_model(Sale, pk).association_id
        else:
            return get_url_param(request, view, 'association')

    # One step from sale
    elif Model in { Item, ItemGroup, Order }:
        if hasPk:
            return get_related_model(Model, pk, 'sale').sale.association_id
        else:
            sale_pk = get_url_param(request, view, 'sale')
            return get_related_model(Sale, sale_pk).association_id

    # Multiple steps to sale
    elif Model in { ItemField, OrderLine, OrderLineItem, OrderLineField }:
        if hasPk:
            # Get path from Model to Sale
            path_to_sale = {
                ItemField: ('item', 'sale'),
                OrderLine: ('order', 'sale'),
                OrderLineItem: ('orderline', 'order', 'sale'),
                OrderLineField: ('orderlineitem', 'orderline', 'order', 'sale'),
            }[Model]

            # Get instance with path selected and walk to association_id
            instance = get_related_model(Model, pk, '__'.join(path_to_sale))
            for step in path_to_sale:
                instance = getattr(instance, step)
            return instance.association_id
        else:
            sale_pk = get_url_param(request, view, 'sale')
            if sale_pk:
                return get_related_model(Sale, sale_pk).association_id

            if Model is ItemField:
                item_pk = get_url_param(request, view, 'item')
                return get_related_model(Item, item_pk, 'sale').sale.association_id
            else:
                order_pk = get_url_param(request, view, 'order')
                return get_related_model(Order, order_pk, 'sale').sale.association_id

    raise NotImplementedError(f"Model {Model.__name__} is not managed")


def check_is_manager(request, view) -> bool:

    # Need authenticated user
    if not request.user.is_authenticated:
        return False

    # Admin are managers of everything
    if request.user.is_admin:
        request.is_manager = True
        return True

    # Get the related association
    try:
        asso_id = get_related_asso_id(request, view)
    except InvalidRequest as error:
        if error.code == 'no_related_model':
            asso_id = None
        else:
            raise error

    # No association specified
    if asso_id is None:
        return False

    # Check that the association is real
    if not Association.objects.filter(pk=asso_id).exists():
        raise InvalidRequest(f"Could not retrieve association '{asso_id}'",
                             code='not_found_related_model')

    # Get user's associations and check if is manager
    oauth_client = OAuthAPI(session=request.session)
    user = request.user.get_with_api_data_and_assos(oauth_client)
    request.is_manager = user.is_manager_of(asso_id)
    return request.is_manager


def check_order_ownership(request, view, obj) -> bool:
    """
    Check that the owner of the Order and the user match
    """
    user = request.user
    Model = type(obj)

    # Need authenticated user
    if not user.is_authenticated:
        return False

    # Admin owns the world
    if user.is_admin:
        return True

    if Model is Order:
        return user == obj.owner

    elif Model is OrderLine: