Aller au contenu

Views

EbouticBasketForm = forms.formset_factory(ProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1) module-attribute

CanViewMixin

Bases: GenericContentPermissionMixinBuilder

Ensure the user has permission to view this view's object.

Raises:

Type Description
PermissionDenied

if the user cannot edit this view's object.

IsSubscriberMixin

Bases: AccessMixin

Check if the user is a subscriber.

Raises:

Type Description
PermissionDenied

if the user isn't subscribed.

FragmentMixin

Bases: TemplateResponseMixin, ContextMixin

Make a view buildable as a fragment that can be embedded in a template.

Most fragments are used in two different ways : - in the request/response cycle, like any regular view - in templates, where the rendering is done in another view

This mixin aims to simplify the initial fragment rendering. The rendered fragment will then be able to re-render itself through the request/response cycle if it uses HTMX.

Example

class MyFragment(FragmentMixin, FormView):
    template_name = "app/fragment.jinja"
    form_class = MyForm
    success_url = reverse_lazy("foo:bar")

# in another view :
def some_view(request):
    fragment = MyFragment.as_fragment()
    return render(
        request,
        "app/template.jinja",
        context={"fragment": fragment(request)
    }

# in urls.py
urlpatterns = [
    path("foo/view", some_view),
    path("foo/fragment", MyFragment.as_view()),
]

reload_on_redirect = False class-attribute instance-attribute

If True, this fragment will trigger a full page reload on redirect.

UseFragmentsMixin

Bases: ContextMixin

Mark a view as using fragments.

This mixin is not mandatory (you may as well render manually your fragments in the get_context_data method). However, the interface of this class bring some distinction between fragments and other context data, which may reduce boilerplate.

Example

class FooFragment(FragmentMixin, FormView): ...

class BarFragment(FragmentMixin, FormView): ...

class AdminFragment(FragmentMixin, FormView): ...

class MyView(UseFragmentsMixin, TemplateView)
    template_name = "app/view.jinja"
    fragments = {
        "foo": FooFragment
        "bar": BarFragment(template_name="some_template.jinja")
    }
    fragments_data = {
        "foo": {"some": "data"}  # this will be passed to the FooFragment renderer
    }

    def get_fragments(self):
        res = super().get_fragments()
        if self.request.user.is_superuser:
            res["admin_fragment"] = AdminFragment
        return res

get_fragment_data()

Return eventual data used to initialize the fragments.

Source code in core/views/mixins.py
def get_fragment_data(self) -> dict[str, dict[str, Any]]:
    """Return eventual data used to initialize the fragments."""
    return self.fragment_data if self.fragment_data is not None else {}

get_fragment_context_data()

Return the rendered fragments as context data.

Source code in core/views/mixins.py
def get_fragment_context_data(self) -> dict[str, SafeString]:
    """Return the rendered fragments as context data."""
    res = {}
    data = self.get_fragment_data()
    for name, fragment in self.get_fragments().items():
        is_cls = inspect.isclass(fragment) and issubclass(fragment, FragmentMixin)
        _fragment = fragment.as_fragment() if is_cls else fragment
        fragment_data = data.get(name, {})
        res[name] = _fragment(self.request, **fragment_data)
    return res

BaseBasketForm

Bases: BaseFormSet

BillingInfoForm

Bases: ModelForm

ProductForm(customer, counter, allowed_products, *args, **kwargs)

Bases: Form

Source code in counter/forms.py
def __init__(
    self,
    customer: Customer,
    counter: Counter,
    allowed_products: dict[int, Product],
    *args,
    **kwargs,
):
    self.customer = customer  # Used by formset
    self.counter = counter  # Used by formset
    self.allowed_products = allowed_products
    super().__init__(*args, **kwargs)

BillingInfo

Bases: Model

Represent the billing information of a user, which are required by the 3D-Secure v2 system used by the etransaction module.

to_3dsv2_xml()

Convert the data from this model into a xml usable by the online paying service of the Crédit Agricole bank. see : https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/manuel-dintegration-focus-3ds-v2/principes-generaux/#integration-3dsv2-developpeur-webmaster.

Source code in counter/models.py
def to_3dsv2_xml(self) -> str:
    """Convert the data from this model into a xml usable
    by the online paying service of the Crédit Agricole bank.
    see : `https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/manuel-dintegration-focus-3ds-v2/principes-generaux/#integration-3dsv2-developpeur-webmaster`.
    """
    data = {
        "Address": {
            "FirstName": self.first_name,
            "LastName": self.last_name,
            "Address1": self.address_1,
            "ZipCode": self.zip_code,
            "City": self.city,
            "CountryCode": self.country.numeric,  # ISO-3166-1 numeric code
            "MobilePhone": self.phone_number.as_national.replace(" ", ""),
            "CountryCodeMobilePhone": f"+{self.phone_number.country_code}",
        }
    }
    if self.address_2:
        data["Address"]["Address2"] = self.address_2
    xml = dict2xml(data, wrap="Billing", newlines=False)
    return '<?xml version="1.0" encoding="UTF-8" ?>' + xml

Customer

Bases: Model

Customer data of a User.

It adds some basic customers' information, such as the account ID, and is used by other accounting classes as reference to the customer, rather than using User.

can_buy property

Check if whether this customer has the right to purchase any item.

This must be not confused with the Product.can_be_sold_to(user) method as the present method returns an information about a customer whereas the other tells something about the relation between a User (not a Customer, don't mix them) and a Product.

save(*args, allow_negative=False, **kwargs)

is_selling : tell if the current action is a selling allow_negative : ignored if not a selling. Allow a selling to put the account in negative Those two parameters avoid blocking the save method of a customer if his account is negative.

Source code in counter/models.py
def save(self, *args, allow_negative=False, **kwargs):
    """is_selling : tell if the current action is a selling
    allow_negative : ignored if not a selling. Allow a selling to put the account in negative
    Those two parameters avoid blocking the save method of a customer if his account is negative.
    """
    if self.amount < 0 and not allow_negative:
        raise ValidationError(_("Not enough money"))
    super().save(*args, **kwargs)

update_returnable_balance()

Update all returnable balances of this user to their real amount.

Source code in counter/models.py
def update_returnable_balance(self):
    """Update all returnable balances of this user to their real amount."""

    def purchases_qs(outer_ref: Literal["product_id", "returned_product_id"]):
        return (
            Selling.objects.filter(customer=self, product=OuterRef(outer_ref))
            .values("product")
            .annotate(quantity=Sum("quantity", default=0))
            .values("quantity")
        )

    balances = (
        ReturnableProduct.objects.annotate_balance_for(self)
        .annotate(
            nb_cons=Coalesce(Subquery(purchases_qs("product_id")), 0),
            nb_dcons=Coalesce(Subquery(purchases_qs("returned_product_id")), 0),
        )
        .annotate(new_balance=F("nb_cons") - F("nb_dcons"))
        .values("id", "new_balance")
    )
    updated_balances = [
        ReturnableProductBalance(
            customer=self, returnable_id=b["id"], balance=b["new_balance"]
        )
        for b in balances
    ]
    ReturnableProductBalance.objects.bulk_create(
        updated_balances,
        update_conflicts=True,
        update_fields=["balance"],
        unique_fields=["customer", "returnable"],
    )

get_or_create(user) classmethod

Work in pretty much the same way as the usual get_or_create method, but with the default field replaced by some under the hood.

If the user has an account, return it as is. Else create a new account with no money on it and a new unique account id

Example : ::

user = User.objects.get(pk=1)
account, created = Customer.get_or_create(user)
if created:
    print(f"created a new account with id {account.id}")
else:
    print(f"user has already an account, with {account.id} € on it"
Source code in counter/models.py
@classmethod
def get_or_create(cls, user: User) -> tuple[Customer, bool]:
    """Work in pretty much the same way as the usual get_or_create method,
    but with the default field replaced by some under the hood.

    If the user has an account, return it as is.
    Else create a new account with no money on it and a new unique account id

    Example : ::

        user = User.objects.get(pk=1)
        account, created = Customer.get_or_create(user)
        if created:
            print(f"created a new account with id {account.id}")
        else:
            print(f"user has already an account, with {account.id} € on it"
    """
    if hasattr(user, "customer"):
        return user.customer, False

    # account_id are always a number with a letter appended
    account_id = (
        Customer.objects.order_by(Length("account_id"), "account_id")
        .values("account_id")
        .last()
    )
    if account_id is None:
        # legacy from the old site
        account = cls.objects.create(user=user, account_id="1504a")
        return account, True

    account_id = account_id["account_id"]
    account_num = int(account_id[:-1])
    while Customer.objects.filter(account_id=account_id).exists():
        # when entering the first iteration, we are using an already existing account id
        # so the loop should always execute at least one time
        account_num += 1
        account_id = f"{account_num}{random.choice(string.ascii_lowercase)}"

    account = cls.objects.create(user=user, account_id=account_id)
    return account, True

Product

Bases: Model

A product, with all its related information.

is_owned_by(user)

Method to see if that object can be edited by the given user.

Source code in counter/models.py
def is_owned_by(self, user):
    """Method to see if that object can be edited by the given user."""
    if user.is_anonymous:
        return False
    return user.is_in_group(
        pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
    ) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)

can_be_sold_to(user)

Check if whether the user given in parameter has the right to buy this product or not.

This must be not confused with the Customer.can_buy() method as the present method returns an information about the relation between a User and a Product, whereas the other tells something about a Customer (and not a user, they are not the same model).

Returns:

Type Description
bool

True if the user can buy this product else False

Warning

This performs a db query, thus you can quickly have a N+1 queries problem if you call it in a loop. Hopefully, you can avoid that if you prefetch the buying_groups :

user = User.objects.get(username="foobar")
products = [
    p
    for p in Product.objects.prefetch_related("buying_groups")
    if p.can_be_sold_to(user)
]
Source code in counter/models.py
def can_be_sold_to(self, user: User) -> bool:
    """Check if whether the user given in parameter has the right to buy
    this product or not.

    This must be not confused with the Customer.can_buy()
    method as the present method returns an information
    about the relation between a User and a Product,
    whereas the other tells something about a Customer
    (and not a user, they are not the same model).

    Returns:
        True if the user can buy this product else False

    Warning:
        This performs a db query, thus you can quickly have
        a N+1 queries problem if you call it in a loop.
        Hopefully, you can avoid that if you prefetch the buying_groups :

        ```python
        user = User.objects.get(username="foobar")
        products = [
            p
            for p in Product.objects.prefetch_related("buying_groups")
            if p.can_be_sold_to(user)
        ]
        ```
    """
    buying_groups = list(self.buying_groups.all())
    if not buying_groups:
        return True
    return any(user.is_in_group(pk=group.id) for group in buying_groups)

Selling

Bases: Model

Handle the sellings.

save(*args, allow_negative=False, **kwargs)

allow_negative : Allow this selling to use more money than available for this user.

Source code in counter/models.py
def save(self, *args, allow_negative=False, **kwargs):
    """allow_negative : Allow this selling to use more money than available for this user."""
    if not self.date:
        self.date = timezone.now()
    self.full_clean()
    if not self.is_validated:
        self.customer.amount -= self.quantity * self.unit_price
        self.customer.save(allow_negative=allow_negative)
        self.is_validated = True
    user = self.customer.user
    if user.was_subscribed:
        if (
            self.product
            and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER
        ):
            sub = Subscription(
                member=user,
                subscription_type="un-semestre",
                payment_method="EBOUTIC",
                location="EBOUTIC",
            )
            sub.subscription_start = Subscription.compute_start()
            sub.subscription_start = Subscription.compute_start(
                duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][
                    "duration"
                ]
            )
            sub.subscription_end = Subscription.compute_end(
                duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][
                    "duration"
                ],
                start=sub.subscription_start,
            )
            sub.save()
        elif (
            self.product
            and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS
        ):
            sub = Subscription(
                member=user,
                subscription_type="deux-semestres",
                payment_method="EBOUTIC",
                location="EBOUTIC",
            )
            sub.subscription_start = Subscription.compute_start()
            sub.subscription_start = Subscription.compute_start(
                duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][
                    "duration"
                ]
            )
            sub.subscription_end = Subscription.compute_end(
                duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][
                    "duration"
                ],
                start=sub.subscription_start,
            )
            sub.save()
    if user.preferences.notify_on_click:
        Notification(
            user=user,
            url=reverse(
                "core:user_account_detail",
                kwargs={
                    "user_id": user.id,
                    "year": self.date.year,
                    "month": self.date.month,
                },
            ),
            param="%d x %s" % (self.quantity, self.label),
            type="SELLING",
        ).save()
    super().save(*args, **kwargs)
    if hasattr(self.product, "eticket"):
        self.send_mail_customer()

Basket

Bases: Model

Basket is built when the user connects to an eboutic page.

generate_sales(counter, seller, payment_method)

Generate a list of sold items corresponding to the items of this basket WITHOUT saving them NOR deleting the basket.

Example
counter = Counter.objects.get(name="Eboutic")
sales = basket.generate_sales(counter, "SITH_ACCOUNT")
# here the basket is in the same state as before the method call

with transaction.atomic():
    for sale in sales:
        sale.save()
    basket.delete()
    # all the basket items are deleted by the on_delete=CASCADE relation
    # thus only the sales remain
Source code in eboutic/models.py
def generate_sales(self, counter, seller: User, payment_method: str):
    """Generate a list of sold items corresponding to the items
    of this basket WITHOUT saving them NOR deleting the basket.

    Example:
        ```python
        counter = Counter.objects.get(name="Eboutic")
        sales = basket.generate_sales(counter, "SITH_ACCOUNT")
        # here the basket is in the same state as before the method call

        with transaction.atomic():
            for sale in sales:
                sale.save()
            basket.delete()
            # all the basket items are deleted by the on_delete=CASCADE relation
            # thus only the sales remain
        ```
    """
    # I must proceed with two distinct requests instead of
    # only one with a join because the AbstractBaseItem model has been
    # poorly designed. If you refactor the model, please refactor this too.
    items = self.items.order_by("product_id")
    ids = [item.product_id for item in items]
    products = Product.objects.filter(id__in=ids).order_by("id")
    # items and products are sorted in the same order
    sales = []
    for item, product in zip(items, products, strict=False):
        sales.append(
            Selling(
                label=product.name,
                counter=counter,
                club=product.club,
                product=product,
                seller=seller,
                customer=Customer.get_or_create(self.user)[0],
                unit_price=item.product_unit_price,
                quantity=item.quantity,
                payment_method=payment_method,
            )
        )
    return sales

BasketItem

Bases: AbstractBaseItem

from_product(product, quantity, basket) classmethod

Create a BasketItem with the same characteristics as the product passed in parameters, with the specified quantity.

Warning

the basket field is not filled, so you must set it yourself before saving the model.

Source code in eboutic/models.py
@classmethod
def from_product(cls, product: Product, quantity: int, basket: Basket):
    """Create a BasketItem with the same characteristics as the
    product passed in parameters, with the specified quantity.

    Warning:
        the basket field is not filled, so you must set
        it yourself before saving the model.
    """
    return cls(
        basket=basket,
        product_id=product.id,
        product_name=product.name,
        type_id=product.product_type_id,
        quantity=quantity,
        product_unit_price=product.selling_price,
    )

BillingInfoState

Bases: Enum

Invoice

Bases: Model

Invoices are generated once the payment has been validated.

InvoiceItem

BaseEbouticBasketForm

EbouticMainView

Bases: LoginRequiredMixin, FormView

Main view of the eboutic application.

The purchasable products are those of the eboutic which belong to a category of products of a product category (orphan products are inaccessible).

EurokPartnerFragment

Bases: IsSubscriberMixin, TemplateView

BillingInfoFormFragment

Bases: LoginRequiredMixin, FragmentMixin, SuccessMessageMixin, UpdateView

Update billing info

EbouticCheckout

Bases: CanViewMixin, UseFragmentsMixin, DetailView

EbouticPayWithSith

Bases: CanViewMixin, SingleObjectMixin, View

EtransactionAutoAnswer

Bases: View

get_eboutic()

Source code in counter/models.py
def get_eboutic() -> Counter:
    return Counter.objects.filter(type="EBOUTIC").order_by("id").first()

get_eboutic_products(user)

Source code in eboutic/models.py
def get_eboutic_products(user: User) -> list[Product]:
    products = (
        get_eboutic()
        .products.filter(product_type__isnull=False)
        .filter(archived=False)
        .filter(limit_age__lte=user.age)
        .annotate(order=F("product_type__order"))
        .annotate(category=F("product_type__name"))
        .annotate(category_comment=F("product_type__comment"))
        .annotate(price=F("selling_price"))  # <-- selected price for basket validation
        .prefetch_related("buying_groups")  # <-- used in `Product.can_be_sold_to`
    )
    return [p for p in products if p.can_be_sold_to(user)]

payment_result(request, result)

Source code in eboutic/views.py
@require_GET
@login_required
def payment_result(request, result: str) -> HttpResponse:
    context = {"success": result == "success"}
    return render(request, "eboutic/eboutic_payment_result.jinja", context)