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()

get_barmen_list()

Returns the barman list as list of User.

Also handle the timeout of the barmen

Source code in counter/models.py
def get_barmen_list(self) -> list[User]:
    """Returns the barman list as list of User.

    Also handle the timeout of the barmen
    """
    perms = self.permanencies.filter(end=None)

    # disconnect barmen who are inactive
    timeout = timezone.now() - timedelta(minutes=settings.SITH_BARMAN_TIMEOUT)
    perms.filter(activity__lte=timeout).update(end=F("activity"))

    return [p.user for p in perms.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())

is_inactive()

Returns True if the counter self is inactive from SITH_COUNTER_MINUTE_INACTIVE's value minutes, else False.

Source code in counter/models.py
def is_inactive(self) -> bool:
    """Returns True if the counter self is inactive from SITH_COUNTER_MINUTE_INACTIVE's value minutes, else False."""
    return self.is_open and (
        (timezone.now() - self.permanencies.order_by("-activity").first().activity)
        > timedelta(minutes=settings.SITH_COUNTER_MINUTE_INACTIVE)
    )

barman_list()

Returns the barman id list.

Source code in counter/models.py
def barman_list(self) -> list[int]:
    """Returns the barman id list."""
    return [b.id for b in self.barmen_list]

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
    if user.is_in_group(
        pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
    ) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID):
        return True
    return False

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

Warns:

Type Description
Hopefully, you can avoid that if you prefetch the buying_groups
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

    Warnings:
        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
    for group in buying_groups:
        if user.is_in_group(pk=group.id):
            return True
    return False

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()
    if self.error_messages:
        return False
    return True

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.

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.

    Warnings:
        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

PurchaseItemSchema

Bases: Schema

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