Aller au contenu

Views

PurchaseItemList = TypeAdapter(list[PurchaseItemSchema]) module-attribute

BillingInfoForm

Bases: ModelForm

Counter

Bases: Model

gen_token()

Generate a new token for this counter.

Source code in counter/models.py
def gen_token(self) -> None:
    """Generate a new token for this counter."""
    self.token = "".join(
        random.choice(string.ascii_letters + string.digits) for _ in range(30)
    )
    self.save()

barmen_list()

Returns the barman list as list of User.

Source code in counter/models.py
@cached_property
def barmen_list(self) -> list[User]:
    """Returns the barman list as list of User."""
    return [
        p.user for p in self.permanencies.filter(end=None).select_related("user")
    ]

get_random_barman()

Return a random user being currently a barman.

Source code in counter/models.py
def get_random_barman(self) -> User:
    """Return a random user being currently a barman."""
    return random.choice(self.barmen_list)

update_activity()

Update the barman activity to prevent timeout.

Source code in counter/models.py
def update_activity(self) -> None:
    """Update the barman activity to prevent timeout."""
    self.permanencies.filter(end=None).update(activity=timezone.now())

can_refill()

Show if the counter authorize the refilling with physic money.

Source code in counter/models.py
def can_refill(self) -> bool:
    """Show if the counter authorize the refilling with physic money."""
    if self.type != "BAR":
        return False
    if self.id in SITH_COUNTER_OFFICES:
        # If the counter is either 'AE' or 'BdF', refills are authorized
        return True
    # at least one of the barmen is in the AE board
    ae = Club.objects.get(unix_name=SITH_MAIN_CLUB["unix_name"])
    return any(ae.get_membership_for(barman) for barman in self.barmen_list)

get_top_barmen()

Return a QuerySet querying the office hours stats of all the barmen of all time of this counter, ordered by descending number of hours.

Each element of the QuerySet corresponds to a barman and has the following data
  • the full name (first name + last name) of the barman
  • the nickname of the barman
  • the promo of the barman
  • the total number of office hours the barman did attend
Source code in counter/models.py
def get_top_barmen(self) -> QuerySet:
    """Return a QuerySet querying the office hours stats of all the barmen of all time
    of this counter, ordered by descending number of hours.

    Each element of the QuerySet corresponds to a barman and has the following data :
        - the full name (first name + last name) of the barman
        - the nickname of the barman
        - the promo of the barman
        - the total number of office hours the barman did attend
    """
    return (
        self.permanencies.exclude(end=None)
        .annotate(
            name=Concat(F("user__first_name"), Value(" "), F("user__last_name"))
        )
        .annotate(nickname=F("user__nick_name"))
        .annotate(promo=F("user__promo"))
        .values("user", "name", "nickname", "promo")
        .annotate(perm_sum=Sum(F("end") - F("start")))
        .exclude(perm_sum=None)
        .order_by("-perm_sum")
    )

get_top_customers(since=None)

Return a QuerySet querying the money spent by customers of this counter since the specified date, ordered by descending amount of money spent.

Each element of the QuerySet corresponds to a customer and has the following data :

  • the full name (first name + last name) of the customer
  • the nickname of the customer
  • the amount of money spent by the customer

Parameters:

Name Type Description Default
since datetime | date | None

timestamp from which to perform the calculation

None
Source code in counter/models.py
def get_top_customers(self, since: datetime | date | None = None) -> QuerySet:
    """Return a QuerySet querying the money spent by customers of this counter
    since the specified date, ordered by descending amount of money spent.

    Each element of the QuerySet corresponds to a customer and has the following data :

    - the full name (first name + last name) of the customer
    - the nickname of the customer
    - the amount of money spent by the customer

    Args:
        since: timestamp from which to perform the calculation
    """
    if since is None:
        since = get_start_of_semester()
    if isinstance(since, date):
        since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)
    return (
        self.sellings.filter(date__gte=since)
        .annotate(
            name=Concat(
                F("customer__user__first_name"),
                Value(" "),
                F("customer__user__last_name"),
            )
        )
        .annotate(nickname=F("customer__user__nick_name"))
        .annotate(promo=F("customer__user__promo"))
        .annotate(user=F("customer__user"))
        .values("user", "promo", "name", "nickname")
        .annotate(
            selling_sum=Sum(
                F("unit_price") * F("quantity"), output_field=CurrencyField()
            )
        )
        .filter(selling_sum__gt=0)
        .order_by("-selling_sum")
    )

get_total_sales(since=None)

Compute and return the total turnover of this counter since the given date.

By default, the date is the start of the current semester.

Parameters:

Name Type Description Default
since datetime | date | None

timestamp from which to perform the calculation

None

Returns:

Type Description
CurrencyField

Total revenue earned at this counter.

Source code in counter/models.py
def get_total_sales(self, since: datetime | date | None = None) -> CurrencyField:
    """Compute and return the total turnover of this counter since the given date.

    By default, the date is the start of the current semester.

    Args:
        since: timestamp from which to perform the calculation

    Returns:
        Total revenue earned at this counter.
    """
    if since is None:
        since = get_start_of_semester()
    if isinstance(since, date):
        since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)
    return self.sellings.filter(date__gte=since).aggregate(
        total=Sum(
            F("quantity") * F("unit_price"),
            default=0,
            output_field=CurrencyField(),
        )
    )["total"]

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: bool 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, is_selling=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, is_selling=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 (is_selling and not allow_negative):
        raise ValidationError(_("Not enough money"))
    super().save(*args, **kwargs)

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)

BasketForm(request)

Class intended to perform checks on the request sended to the server when the user submits his basket from /eboutic/.

Because it must check an unknown number of fields, coming from a cookie and needing some databases checks to be performed, inheriting from forms.Form or using formset would have been likely to end in a big ball of wibbly-wobbly hacky stuff. Thus this class is a pure standalone and performs its operations by its own means. However, it still tries to share some similarities with a standard django Form.

Examples:

::

def my_view(request):
    form = BasketForm(request)
    form.clean()
    if form.is_valid():
        # perform operations
    else:
        errors = form.get_error_messages()

        # return the cookie that was in the request, but with all
        # incorrects elements removed
        cookie = form.get_cleaned_cookie()

You can also use a little shortcut by directly calling form.is_valid() without calling form.clean(). In this case, the latter method shall be implicitly called.

Source code in eboutic/forms.py
def __init__(self, request: HttpRequest):
    self.user = request.user
    self.cookies = request.COOKIES
    self.error_messages = set()
    self.correct_items = []

clean()

Perform all the checks, but return nothing. To know if the form is valid, the is_valid() method must be used.

The form shall be considered as valid if it meets all the following conditions
  • it contains a "basket_items" key in the cookies of the request given in the constructor
  • this cookie is a list of objects formatted this way : [{'id': <int>, 'quantity': <int>, 'name': <str>, 'unit_price': <float>}, ...]. The order of the fields in each object does not matter
  • all the ids are positive integers
  • all the ids refer to products available in the EBOUTIC
  • all the ids refer to products the user is allowed to buy
  • all the quantities are positive integers
Source code in eboutic/forms.py
def clean(self) -> None:
    """Perform all the checks, but return nothing.
    To know if the form is valid, the `is_valid()` method must be used.

    The form shall be considered as valid if it meets all the following conditions :
        - it contains a "basket_items" key in the cookies of the request given in the constructor
        - this cookie is a list of objects formatted this way : `[{'id': <int>, 'quantity': <int>,
         'name': <str>, 'unit_price': <float>}, ...]`. The order of the fields in each object does not matter
        - all the ids are positive integers
        - all the ids refer to products available in the EBOUTIC
        - all the ids refer to products the user is allowed to buy
        - all the quantities are positive integers
    """
    try:
        basket = PurchaseItemList.validate_json(
            unquote(self.cookies.get("basket_items", "[]"))
        )
    except ValidationError:
        self.error_messages.add(_("The request was badly formatted."))
        return
    if len(basket) == 0:
        self.error_messages.add(_("Your basket is empty."))
        return
    existing_ids = {product.id for product in get_eboutic_products(self.user)}
    for item in basket:
        # check a product with this id does exist
        if item.product_id in existing_ids:
            self.correct_items.append(item)
        else:
            self.error_messages.add(
                _(
                    "%(name)s : this product does not exist or may no longer be available."
                )
                % {"name": item.name}
            )
            continue

is_valid()

Return True if the form is correct else False.

If the clean() method has not been called beforehand, call it.

Source code in eboutic/forms.py
def is_valid(self) -> bool:
    """Return True if the form is correct else False.

    If the `clean()` method has not been called beforehand, call it.
    """
    if not self.error_messages and not self.correct_items:
        self.clean()
    return not self.error_messages

Basket

Bases: Model

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

from_session(session) classmethod

The basket stored in the session object, if it exists.

Source code in eboutic/models.py
@classmethod
def from_session(cls, session) -> Basket | None:
    """The basket stored in the session object, if it exists."""
    if "basket_id" in session:
        return cls.objects.filter(id=session["basket_id"]).first()
    return None

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):
        sales.append(
            Selling(
                label=product.name,
                counter=counter,
                club=product.club,
                product=product,
                seller=seller,
                customer=self.user.customer,
                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,
    )

Invoice

Bases: Model

Invoices are generated once the payment has been validated.

InvoiceItem

Bases: AbstractBaseItem

PurchaseItemSchema

Bases: Schema

BillingInfoState

Bases: Enum

EbouticCommand

Bases: LoginRequiredMixin, TemplateView

EtransactionAutoAnswer

Bases: View

get_eboutic_products(user)

Source code in eboutic/models.py
def get_eboutic_products(user: User) -> list[Product]:
    products = (
        Counter.objects.get(type="EBOUTIC")
        .products.filter(product_type__isnull=False)
        .filter(archived=False)
        .filter(limit_age__lte=user.age)
        .annotate(priority=F("product_type__priority"))
        .annotate(category=F("product_type__name"))
        .annotate(category_comment=F("product_type__comment"))
        .prefetch_related("buying_groups")  # <-- used in `Product.can_be_sold_to`
    )
    return [p for p in products if p.can_be_sold_to(user)]

eboutic_main(request)

Main view of the eboutic application.

Return an Http response whose content is of type text/html. The latter represents the page from which a user can see the catalogue of products that he can buy and fill his shopping cart.

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

If the session contains a key-value pair that associates "errors" with a list of strings, this pair is removed from the session and its value displayed to the user when the page is rendered.

Source code in eboutic/views.py
@login_required
@require_GET
def eboutic_main(request: HttpRequest) -> HttpResponse:
    """Main view of the eboutic application.

    Return an Http response whose content is of type text/html.
    The latter represents the page from which a user can see
    the catalogue of products that he can buy and fill
    his shopping cart.

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

    If the session contains a key-value pair that associates "errors"
    with a list of strings, this pair is removed from the session
    and its value displayed to the user when the page is rendered.
    """
    errors = request.session.pop("errors", None)
    products = get_eboutic_products(request.user)
    context = {
        "errors": errors,
        "products": products,
        "customer_amount": request.user.account_balance,
    }
    return render(request, "eboutic/eboutic_main.jinja", context)

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)

e_transaction_data(request)

Source code in eboutic/views.py
@login_required
@require_GET
def e_transaction_data(request):
    basket = Basket.from_session(request.session)
    if basket is None:
        return HttpResponse(status=404, content=json.dumps({"data": []}))
    data = basket.get_e_transaction_data()
    data = {"data": [{"key": key, "value": val} for key, val in data]}
    return HttpResponse(status=200, content=json.dumps(data))

pay_with_sith(request)

Source code in eboutic/views.py
@login_required
@require_POST
def pay_with_sith(request):
    basket = Basket.from_session(request.session)
    refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
    if basket is None or basket.items.filter(type_id=refilling).exists():
        return redirect("eboutic:main")
    c = Customer.objects.filter(user__id=basket.user_id).first()
    if c is None:
        return redirect("eboutic:main")
    if c.amount < basket.total:
        res = redirect("eboutic:payment_result", "failure")
        res.delete_cookie("basket_items", "/eboutic")
        return res
    eboutic = Counter.objects.get(type="EBOUTIC")
    sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
    try:
        with transaction.atomic():
            # Selling.save has some important business logic in it.
            # Do not bulk_create this
            for sale in sales:
                sale.save()
            basket.delete()
        request.session.pop("basket_id", None)
        res = redirect("eboutic:payment_result", "success")
    except DatabaseError as e:
        with sentry_sdk.push_scope() as scope:
            scope.user = {"username": request.user.username}
            scope.set_extra("someVariable", e.__repr__())
            sentry_sdk.capture_message(
                f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
            )
        res = redirect("eboutic:payment_result", "failure")
    res.delete_cookie("basket_items", "/eboutic")
    return res