Aller au contenu

Others

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.

CloseCustomerAccountForm

Bases: Form

CounterEditForm(*args, user, instance, **kwargs)

Bases: ModelForm

Source code in counter/forms.py
def __init__(self, *args, user: User, instance: Counter, **kwargs):
    super().__init__(*args, instance=instance, **kwargs)
    # if the user is an admin, he will have access to all products,
    # else only to active products owned by the counter's club
    # or already on the counter
    if user.has_perm("counter.change_counter"):
        self.fields["products"].widget = AutoCompleteSelectMultipleProduct()
    else:
        # updating the queryset of the field also updates the choices of
        # the widget, so it's important to set the queryset after the widget
        self.fields["products"].widget = AutoCompleteSelectMultiple()
        self.fields["products"].queryset = Product.objects.filter(
            Q(club_id=instance.club_id) | Q(counters=instance), archived=False
        ).distinct()
        self.fields["products"].help_text = _(
            "If you want to add a product that is not owned by "
            "your club to this counter, you should ask an admin."
        )
    self.fields["sellers_regular"].initial = self.instance.sellers.filter(
        countersellers__is_regular=True
    ).all()
    self.fields["sellers_temporary"].initial = self.instance.sellers.filter(
        countersellers__is_regular=False
    ).all()

ProductForm(*args, prefix=None, instance=None, **kwargs)

Bases: ModelForm

Source code in counter/forms.py
def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs):
    super().__init__(*args, prefix=prefix, instance=instance, **kwargs)
    self.fields["name"].widget.attrs["autofocus"] = "autofocus"
    if self.instance.id:
        self.fields["counters"].initial = self.instance.counters.all()
        if hasattr(self.instance, "formula"):
            self.formula_init(self.instance.formula)
    self.price_formset = ProductPriceFormSet(
        *args, instance=self.instance, prefix="price", **kwargs
    )
    self.action_formset = ScheduledProductActionFormSet(
        *args, product=self.instance, prefix="action", **kwargs
    )

ProductFormulaForm

Bases: ModelForm

ReturnableProductForm

Bases: ModelForm

Counter

Bases: Model

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")
    ]

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.

Refills are authorized if a user having the required permission is currently logged in.

Source code in counter/models.py
@cached_property
def can_refill(self) -> bool:
    """Show if the counter authorize the refilling with physic money.

    Refills are authorized if a user having the required permission
    is currently logged in.
    """
    return self.type == "BAR" and (
        User.objects.with_perm("counter.add_refilling")
        .filter(id__in=[u.id for u in self.barmen_list])
        .exists()
    )

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
    """
    name_expr = Concat(F("user__first_name"), Value(" "), F("user__last_name"))
    return (
        self.permanencies.exclude(end=None)
        .annotate(
            name=name_expr, nickname=F("user__nick_name"), 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)
    name_expr = Concat(
        F("customer__user__first_name"), Value(" "), F("customer__user__last_name")
    )
    return (
        self.sellings.filter(date__gte=since)
        .annotate(
            name=name_expr,
            nickname=F("customer__user__nick_name"),
            promo=F("customer__user__promo"),
            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_is_barman(customer)

Check if this counter is a bar and if the customer is currently logged in. This is useful to compute special prices.

Source code in counter/models.py
def customer_is_barman(self, customer: Customer | User) -> bool:
    """Check if this counter is a `bar` and if the customer is currently logged in.
    This is useful to compute special prices."""

    # Customer and User are two different tables,
    # but they share the same primary key
    return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)

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)

ProductFormula

Bases: Model

ProductType

Bases: OrderedModel

A product type.

Useful only for categorizing.

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)

Refilling

Bases: Model

Handle the refilling.

ReturnableProduct

Bases: Model

A returnable relation between two products (consigne/déconsigne).

update_balances()

Update all returnable balances linked to this object.

Call this when a ReturnableProduct is created or updated.

Warning

This function is expensive (around a few seconds), so try not to run it outside a management command or a task.

Source code in counter/models.py
def update_balances(self):
    """Update all returnable balances linked to this object.

    Call this when a ReturnableProduct is created or updated.

    Warning:
        This function is expensive (around a few seconds),
        so try not to run it outside a management command
        or a task.
    """

    def product_balance_subquery(product_id: int):
        return Subquery(
            Selling.objects.filter(customer=OuterRef("pk"), product_id=product_id)
            .values("customer")
            .annotate(res=Sum("quantity"))
            .values("res")
        )

    old_balance_subquery = Subquery(
        ReturnableProductBalance.objects.filter(
            customer=OuterRef("pk"), returnable=self
        ).values("balance")
    )
    new_balances = (
        Customer.objects.annotate(
            nb_cons=Coalesce(product_balance_subquery(self.product_id), 0),
            nb_dcons=Coalesce(
                product_balance_subquery(self.returned_product_id), 0
            ),
        )
        .annotate(new_balance=F("nb_cons") - F("nb_dcons"))
        .exclude(new_balance=Coalesce(old_balance_subquery, 0))
        .values("pk", "new_balance")
    )
    updates = [
        ReturnableProductBalance(
            customer_id=c["pk"], returnable=self, balance=c["new_balance"]
        )
        for c in new_balances
    ]
    ReturnableProductBalance.objects.bulk_create(
        updates,
        update_conflicts=True,
        update_fields=["balance"],
        unique_fields=["customer_id", "returnable"],
    )

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 (
        self._state.adding
        and self.payment_method == self.PaymentMethod.SITH_ACCOUNT
    ):
        self.customer.amount -= self.quantity * self.unit_price
        self.customer.save(allow_negative=allow_negative)
    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()

CounterAdminTabsMixin

CounterListView

Bases: CounterAdminTabsMixin, CanViewMixin, ListView

A list view for the admins.

CounterEditView

Bases: CounterAdminTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView

Edit a counter's main informations (for the counter's manager).

CounterEditPropView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView

Edit a counter's main infos.

CounterCreateView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, CreateView

Create a counter (for the admins).

CounterDeleteView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView

Delete a counter (for the admins).

ProductTypeListView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, ListView

A list view for the admins.

ProductTypeCreateView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, CreateView

A create view for the admins.

ProductTypeEditView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView

An edit view for the admins.

ProductListView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, TemplateView

ProductCreateView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, CreateView

A create view for the admins.

ProductEditView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView

An edit view for the admins.

ProductFormulaListView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, ListView

ProductFormulaCreateView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, CreateView

ProductFormulaEditView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView

ProductFormulaDeleteView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView

ReturnableProductListView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, ListView

ReturnableProductCreateView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, CreateView

ReturnableProductUpdateView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView

ReturnableProductDeleteView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView

CounterTransactionDeletionView

Bases: DeleteView

RefillingDeleteView

Bases: CounterTransactionDeletionView

Delete a refilling (for the admins).

SellingDeleteView

Bases: CounterTransactionDeletionView

Delete a selling (for the admins).

CounterStatView

Bases: PermissionRequiredMixin, DetailView

Show the bar stats.

get_context_data(**kwargs)

Add stats to the context.

Source code in counter/views/admin.py
def get_context_data(self, **kwargs):
    """Add stats to the context."""
    counter: Counter = self.object
    start_date = get_start_of_semester()
    semester_start = datetime(
        start_date.year,
        start_date.month,
        start_date.day,
        tzinfo=get_current_timezone(),
    )
    office_hours = counter.get_top_barmen()
    kwargs = super().get_context_data(**kwargs)
    kwargs.update(
        {
            "counter": counter,
            "current_semester": get_semester_code(),
            "total_sellings": counter.get_total_sales(since=semester_start),
            "top_customers": counter.get_top_customers(since=semester_start)[:100],
            "top_barman": office_hours[:100],
            "top_barman_semester": (
                office_hours.filter(start__gt=semester_start)[:100]
            ),
        }
    )
    return kwargs

CounterRefillingListView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, ListView

List of refillings on a counter.

RefoundAccountView

Bases: UserPassesTestMixin, SuccessMessageMixin, FormView

Create a selling with the same amount as the current user money.

get_semester_code(d=None)

Return the semester code of the given date. If no date is given, return the semester code of the current semester.

The semester code is an upper letter (A for autumn, P for spring), followed by the last two digits of the year. For example, the autumn semester of 2018 is "A18".

Parameters:

Name Type Description Default
d date | None

the date to use to compute the semester. If None, use today's date.

None

Returns:

Type Description
str

the semester code corresponding to the given date

Source code in core/utils.py
def get_semester_code(d: date | None = None) -> str:
    """Return the semester code of the given date.
    If no date is given, return the semester code of the current semester.

    The semester code is an upper letter (A for autumn, P for spring),
    followed by the last two digits of the year.
    For example, the autumn semester of 2018 is "A18".

    Args:
        d: the date to use to compute the semester. If None, use today's date.

    Returns:
        the semester code corresponding to the given date
    """
    if d is None:
        d = localdate()

    start = get_start_of_semester(d)

    if (start.month, start.day) == settings.SITH_SEMESTER_START_AUTUMN:
        return "A" + str(start.year)[-2:]
    return "P" + str(start.year)[-2:]

get_start_of_semester(today=None)

Return the date of the start of the semester of the given date. If no date is given, return the start date of the current semester.

The current semester is computed as follows:

  • If the date is between 15/08 and 31/12 => Autumn semester.
  • If the date is between 01/01 and 15/02 => Autumn semester of the previous year.
  • If the date is between 15/02 and 15/08 => Spring semester

Parameters:

Name Type Description Default
today date | None

the date to use to compute the semester. If None, use today's date.

None

Returns:

Type Description
date

the date of the start of the semester

Source code in core/utils.py
def get_start_of_semester(today: date | None = None) -> date:
    """Return the date of the start of the semester of the given date.
    If no date is given, return the start date of the current semester.

    The current semester is computed as follows:

    - If the date is between 15/08 and 31/12  => Autumn semester.
    - If the date is between 01/01 and 15/02  => Autumn semester of the previous year.
    - If the date is between 15/02 and 15/08  => Spring semester

    Args:
        today: the date to use to compute the semester. If None, use today's date.

    Returns:
        the date of the start of the semester
    """
    if today is None:
        today = localdate()

    autumn = date(today.year, *settings.SITH_SEMESTER_START_AUTUMN)
    spring = date(today.year, *settings.SITH_SEMESTER_START_SPRING)

    if today >= autumn:  # between 15/08 (included) and 31/12 -> autumn semester
        return autumn
    if today >= spring:  # between 15/02 (included) and 15/08 -> spring semester
        return spring
    # between 01/01 and 15/02 -> autumn semester of the previous year
    return autumn.replace(year=autumn.year - 1)

is_logged_in_counter(request, counter=None)

Check if the request is sent from a device logged to a counter.

The request must also be sent within the frame of a counter's activity. Trying to use this function to manage access to non-sas related resources probably won't work.

A request is considered as coming from a logged counter if :

  • Its referer comes from the counter app (eg. fetching user pictures from the click UI) or the request path belongs to the counter app (eg. the barman went back to the main by missclick and go back to the counter)
  • There are barmen logged in the current session
Source code in counter/utils.py
def is_logged_in_counter(request: HttpRequest, counter: Counter | None = None) -> bool:
    """Check if the request is sent from a device logged to a counter.

    The request must also be sent within the frame of a counter's activity.
    Trying to use this function to manage access to non-sas
    related resources probably won't work.

    A request is considered as coming from a logged counter if :

    - Its referer comes from the counter app
      (eg. fetching user pictures from the click UI)
      or the request path belongs to the counter app
      (eg. the barman went back to the main by missclick and go back
      to the counter)
    - There are barmen logged in the current session
    """
    referer_ok = (
        "HTTP_REFERER" in request.META
        and resolve(urlparse(request.META["HTTP_REFERER"]).path).app_name == "counter"
    )
    if not referer_ok and request.resolver_match.app_name != "counter":
        return False

    if counter is None:
        return bool(request.barmen)
    return bool(request.barmen) and request.barmen.issubset(set(counter.barmen_list))

CashSummaryFormBase

Bases: Form

CashRegisterSummary

Bases: Model

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

CashRegisterSummaryItem

Bases: Model

Counter

Bases: Model

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")
    ]

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.

Refills are authorized if a user having the required permission is currently logged in.

Source code in counter/models.py
@cached_property
def can_refill(self) -> bool:
    """Show if the counter authorize the refilling with physic money.

    Refills are authorized if a user having the required permission
    is currently logged in.
    """
    return self.type == "BAR" and (
        User.objects.with_perm("counter.add_refilling")
        .filter(id__in=[u.id for u in self.barmen_list])
        .exists()
    )

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
    """
    name_expr = Concat(F("user__first_name"), Value(" "), F("user__last_name"))
    return (
        self.permanencies.exclude(end=None)
        .annotate(
            name=name_expr, nickname=F("user__nick_name"), 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)
    name_expr = Concat(
        F("customer__user__first_name"), Value(" "), F("customer__user__last_name")
    )
    return (
        self.sellings.filter(date__gte=since)
        .annotate(
            name=name_expr,
            nickname=F("customer__user__nick_name"),
            promo=F("customer__user__promo"),
            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_is_barman(customer)

Check if this counter is a bar and if the customer is currently logged in. This is useful to compute special prices.

Source code in counter/models.py
def customer_is_barman(self, customer: Customer | User) -> bool:
    """Check if this counter is a `bar` and if the customer is currently logged in.
    This is useful to compute special prices."""

    # Customer and User are two different tables,
    # but they share the same primary key
    return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)

Refilling

Bases: Model

Handle the refilling.

CounterAdminTabsMixin

CounterTabsMixin

User

Bases: AbstractUser

Defines the base user class, useable in every app.

This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).

Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth

is_in_group(*, pk=None, name=None)

Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.

The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.

Returns:

Type Description
bool

True if the user is the group, else False

Source code in core/models.py
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:
    """Check if this user is in the given group.
    Either a group id or a group name must be provided.
    If both are passed, only the id will be considered.

    The group will be fetched using the given parameter.
    If no group is found, return False.
    If a group is found, check if this user is in the latter.

    Returns:
         True if the user is the group, else False
    """
    if not pk and not name:
        raise ValueError("You must either provide the id or the name of the group")
    group_id: int | None = (
        pk or Group.objects.filter(name=name).values_list("id", flat=True).first()
    )
    if group_id is None:
        return False
    return group_id in self.all_groups

all_groups()

Get the list of groups this user is in.

Source code in core/models.py
@cached_property
def all_groups(self) -> dict[int, Group]:
    """Get the list of groups this user is in."""
    additional_groups = []
    if self.is_subscribed:
        additional_groups.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
    if self.is_superuser:
        additional_groups.append(settings.SITH_GROUP_ROOT_ID)
    qs = self.groups.all()
    if additional_groups:
        # This is somewhat counter-intuitive, but this query runs way faster with
        # a UNION rather than a OR (in average, 0.25ms vs 14ms).
        # For the why, cf. https://dba.stackexchange.com/questions/293836/why-is-an-or-statement-slower-than-union
        qs = qs.union(Group.objects.filter(id__in=additional_groups))
    return {g.id: g for g in qs}

age()

Return the age this user has the day the method is called. If the user has not filled his age, return 0.

Source code in core/models.py
@cached_property
def age(self) -> int:
    """Return the age this user has the day the method is called.
    If the user has not filled his age, return 0.
    """
    if self.date_of_birth is None:
        return 0
    today = timezone.now()
    age = today.year - self.date_of_birth.year
    # remove a year if this year's birthday is yet to come
    age -= (today.month, today.day) < (
        self.date_of_birth.month,
        self.date_of_birth.day,
    )
    return age

get_short_name()

Returns the short name for the user.

Source code in core/models.py
def get_short_name(self):
    """Returns the short name for the user."""
    if self.nick_name:
        return self.nick_name
    return self.first_name + " " + self.last_name

get_display_name()

Returns the display name of the user.

A nickname if possible, otherwise, the full name.

Source code in core/models.py
def get_display_name(self) -> str:
    """Returns the display name of the user.

    A nickname if possible, otherwise, the full name.
    """
    if self.nick_name:
        return "%s (%s)" % (self.get_full_name(), self.nick_name)
    return self.get_full_name()

get_family(godfathers_depth=4, godchildren_depth=4)

Get the family of the user, with the given depth.

Parameters:

Name Type Description Default
godfathers_depth NonNegativeInt

The number of generations of godfathers to fetch

4
godchildren_depth NonNegativeInt

The number of generations of godchildren to fetch

4

Returns:

Type Description
set[through]

A list of family relationships in this user's family

Source code in core/models.py
def get_family(
    self,
    godfathers_depth: NonNegativeInt = 4,
    godchildren_depth: NonNegativeInt = 4,
) -> set[User.godfathers.through]:
    """Get the family of the user, with the given depth.

    Args:
        godfathers_depth: The number of generations of godfathers to fetch
        godchildren_depth: The number of generations of godchildren to fetch

    Returns:
        A list of family relationships in this user's family
    """
    res = []
    for depth, key, reverse_key in [
        (godfathers_depth, "from_user_id", "to_user_id"),
        (godchildren_depth, "to_user_id", "from_user_id"),
    ]:
        if depth == 0:
            continue
        links = list(User.godfathers.through.objects.filter(**{key: self.id}))
        res.extend(links)
        for _ in range(1, depth):  # noqa: F402 we don't care about gettext here
            ids = [getattr(c, reverse_key) for c in links]
            links = list(
                User.godfathers.through.objects.filter(
                    **{f"{key}__in": ids}
                ).exclude(id__in=[r.id for r in res])
            )
            if not links:
                break
            res.extend(links)
    return set(res)

email_user(subject, message, from_email=None, **kwargs)

Sends an email to this User.

Source code in core/models.py
def email_user(self, subject, message, from_email=None, **kwargs):
    """Sends an email to this User."""
    if from_email is None:
        from_email = settings.DEFAULT_FROM_EMAIL
    send_mail(subject, message, from_email, [self.email], **kwargs)

generate_username()

Generates a unique username based on the first and last names.

For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.

Returns:

Type Description
str

The generated username.

Source code in core/models.py
def generate_username(self) -> str:
    """Generates a unique username based on the first and last names.

    For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.

    Returns:
        The generated username.
    """

    def remove_accents(data):
        return "".join(
            x
            for x in unicodedata.normalize("NFKD", data)
            if unicodedata.category(x)[0] == "L"
        ).lower()

    user_name = (
        remove_accents(self.first_name[0] + self.last_name)
        .encode("ascii", "ignore")
        .decode("utf-8")
    )
    # load all usernames which could conflict with the new one.
    # we need to actually load them, instead of performing a count,
    # because we cannot be sure that two usernames refer to the
    # actual same word (eg. tmore and tmoreau)
    possible_conflicts: list[str] = list(
        User.objects.filter(username__startswith=user_name).values_list(
            "username", flat=True
        )
    )
    nb_conflicts = sum(
        1 for name in possible_conflicts if name.rstrip(string.digits) == user_name
    )
    if nb_conflicts > 0:
        user_name += str(nb_conflicts)  # exemple => exemple1
    self.username = user_name
    return user_name

is_owner(obj)

Determine if the object is owned by the user.

Source code in core/models.py
def is_owner(self, obj: models.Model):
    """Determine if the object is owned by the user."""
    if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
        return True
    if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group_id):
        return True
    return self.is_root

can_edit(obj)

Determine if the object can be edited by the user.

Source code in core/models.py
def can_edit(self, obj: models.Model):
    """Determine if the object can be edited by the user."""
    if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
        return True
    if hasattr(obj, "edit_groups"):
        if (
            hasattr(obj, "_prefetched_objects_cache")
            and "edit_groups" in obj._prefetched_objects_cache
        ):
            pks = [g.id for g in obj.edit_groups.all()]
        else:
            pks = list(obj.edit_groups.values_list("id", flat=True))
        if any(self.is_in_group(pk=pk) for pk in pks):
            return True
    return self.is_owner(obj)

can_view(obj)

Determine if the object can be viewed by the user.

Source code in core/models.py
def can_view(self, obj: models.Model):
    """Determine if the object can be viewed by the user."""
    if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
        return True
    if hasattr(obj, "view_groups"):
        # if "view_groups" has already been prefetched, use
        # the prefetch cache, else fetch only the ids, to make
        # the query lighter.
        if (
            hasattr(obj, "_prefetched_objects_cache")
            and "view_groups" in obj._prefetched_objects_cache
        ):
            pks = [g.id for g in obj.view_groups.all()]
        else:
            pks = list(obj.view_groups.values_list("id", flat=True))
        if any(self.is_in_group(pk=pk) for pk in pks):
            return True
    return self.can_edit(obj)

can_be_viewed_by(user)

Check if the given user can be viewed by this user.

Given users A and B. A can be viewed by B if :

  • A and B are the same user
  • or B has the permission to view hidden users
  • or B can view users in general and A didn't hide its profile
  • or B is in A's whitelist.
Source code in core/models.py
def can_be_viewed_by(self, user: User) -> bool:
    """Check if the given user can be viewed by this user.

    Given users A and B. A can be viewed by B if :

    - A and B are the same user
    - or B has the permission to view hidden users
    - or B can view users in general and A didn't hide its profile
    - or B is in A's whitelist.
    """

    def is_in_whitelist(u: User):
        if (
            hasattr(self, "_prefetched_objects_cache")
            and "whitelisted_users" in self._prefetched_objects_cache
        ):
            return u in self.whitelisted_users.all()
        return self.whitelisted_users.contains(u)

    return (
        user.id == self.id
        or user.has_perm("core.view_hidden_user")
        or (
            user.has_perm("core.view_user")
            and (self.is_viewable or is_in_whitelist(user))
        )
    )

clubs_with_rights()

The list of clubs where the user has rights

Source code in core/models.py
@cached_property
def clubs_with_rights(self) -> list[Club]:
    """The list of clubs where the user has rights"""
    memberships = self.memberships.ongoing().board().select_related("club")
    return [m.club for m in memberships]

CashRegisterSummaryForm(*args, **kwargs)

Bases: Form

Provide the cash summary form.

Source code in counter/views/cash.py
def __init__(self, *args, **kwargs):
    instance = kwargs.pop("instance", None)
    super().__init__(*args, **kwargs)
    if instance:
        self.fields["ten_cents"].initial = (
            instance.ten_cents.quantity if instance.ten_cents else 0
        )
        self.fields["twenty_cents"].initial = (
            instance.twenty_cents.quantity if instance.twenty_cents else 0
        )
        self.fields["fifty_cents"].initial = (
            instance.fifty_cents.quantity if instance.fifty_cents else 0
        )
        self.fields["one_euro"].initial = (
            instance.one_euro.quantity if instance.one_euro else 0
        )
        self.fields["two_euros"].initial = (
            instance.two_euros.quantity if instance.two_euros else 0
        )
        self.fields["five_euros"].initial = (
            instance.five_euros.quantity if instance.five_euros else 0
        )
        self.fields["ten_euros"].initial = (
            instance.ten_euros.quantity if instance.ten_euros else 0
        )
        self.fields["twenty_euros"].initial = (
            instance.twenty_euros.quantity if instance.twenty_euros else 0
        )
        self.fields["fifty_euros"].initial = (
            instance.fifty_euros.quantity if instance.fifty_euros else 0
        )
        self.fields["hundred_euros"].initial = (
            instance.hundred_euros.quantity if instance.hundred_euros else 0
        )
        self.fields["check_1_quantity"].initial = (
            instance.check_1.quantity if instance.check_1 else 0
        )
        self.fields["check_2_quantity"].initial = (
            instance.check_2.quantity if instance.check_2 else 0
        )
        self.fields["check_3_quantity"].initial = (
            instance.check_3.quantity if instance.check_3 else 0
        )
        self.fields["check_4_quantity"].initial = (
            instance.check_4.quantity if instance.check_4 else 0
        )
        self.fields["check_5_quantity"].initial = (
            instance.check_5.quantity if instance.check_5 else 0
        )
        self.fields["check_1_value"].initial = (
            instance.check_1.value if instance.check_1 else 0
        )
        self.fields["check_2_value"].initial = (
            instance.check_2.value if instance.check_2 else 0
        )
        self.fields["check_3_value"].initial = (
            instance.check_3.value if instance.check_3 else 0
        )
        self.fields["check_4_value"].initial = (
            instance.check_4.value if instance.check_4 else 0
        )
        self.fields["check_5_value"].initial = (
            instance.check_5.value if instance.check_5 else 0
        )
        self.fields["comment"].initial = instance.comment
        self.fields["emptied"].initial = instance.emptied
        self.instance = instance
    else:
        self.instance = None

CounterCashSummaryView

Bases: CounterTabsMixin, PermissionRequiredMixin, BaseFormView, DetailView

Provide the cash summary form.

CashSummaryEditView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView

Edit cash summaries.

CashSummaryListView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, ListView

Display a list of cash summaries.

get_context_data(**kwargs)

Add sums to the context.

Source code in counter/views/cash.py
def get_context_data(self, **kwargs):
    """Add sums to the context."""
    kwargs = super().get_context_data(**kwargs)
    form = CashSummaryFormBase(self.request.GET)
    kwargs["form"] = form
    kwargs["summaries_sums"] = {}
    kwargs["refilling_sums"] = {}
    for c in Counter.objects.filter(type="BAR").all():
        refillings = Refilling.objects.filter(counter=c)
        cash_register_summaries = CashRegisterSummary.objects.filter(counter=c)
        if form.is_valid() and form.cleaned_data["begin_date"]:
            refillings = refillings.filter(
                date__gte=form.cleaned_data["begin_date"]
            )
            cash_register_summaries = cash_register_summaries.filter(
                date__gte=form.cleaned_data["begin_date"]
            )
        else:
            last_summary = (
                CashRegisterSummary.objects.filter(counter=c, emptied=True)
                .order_by("-date")
                .first()
            )
            if last_summary:
                refillings = refillings.filter(date__gt=last_summary.date)
                cash_register_summaries = cash_register_summaries.filter(
                    date__gt=last_summary.date
                )
        if form.is_valid() and form.cleaned_data["end_date"]:
            refillings = refillings.filter(date__lte=form.cleaned_data["end_date"])
            cash_register_summaries = cash_register_summaries.filter(
                date__lte=form.cleaned_data["end_date"]
            )
        kwargs["summaries_sums"][c.name] = sum(
            [s.get_total() for s in cash_register_summaries.all()]
        )
        kwargs["refilling_sums"][c.name] = sum([s.amount for s in refillings.all()])
    return kwargs

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.

EticketForm

Bases: ModelForm

Eticket

Bases: Model

Eticket can be linked to a product an allows PDF generation.

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

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 (
        self._state.adding
        and self.payment_method == self.PaymentMethod.SITH_ACCOUNT
    ):
        self.customer.amount -= self.quantity * self.unit_price
        self.customer.save(allow_negative=allow_negative)
    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()

CounterAdminTabsMixin

EticketListView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, ListView

A list view for the admins.

EticketCreateView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, CreateView

Create an eticket.

EticketEditView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView

Edit an eticket.

EticketPDFView

Bases: CanViewMixin, DetailView

Display the PDF of an eticket.

InvoiceCallForm(*args, month, **kwargs)

Bases: Form

Source code in counter/forms.py
def __init__(self, *args, month: date, **kwargs):
    super().__init__(*args, **kwargs)
    self.month = month
    month_start = datetime(month.year, month.month, month.day, tzinfo=timezone.utc)
    self.clubs = list(
        Club.objects.filter(
            Exists(
                Selling.objects.filter(
                    club=OuterRef("pk"),
                    date__gte=month_start,
                    date__lte=month_start + relativedelta(months=1),
                )
            )
        ).annotate(
            validated_invoice=Exists(
                InvoiceCall.objects.filter(
                    club=OuterRef("pk"), month=month, is_validated=True
                )
            )
        )
    )
    self.fields = {
        str(club.id): forms.BooleanField(
            required=False, initial=club.validated_invoice
        )
        for club in self.clubs
    }

Refilling

Bases: Model

Handle the refilling.

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 (
        self._state.adding
        and self.payment_method == self.PaymentMethod.SITH_ACCOUNT
    ):
        self.customer.amount -= self.quantity * self.unit_price
        self.customer.save(allow_negative=allow_negative)
    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()

CounterAdminTabsMixin

InvoiceCallView

Bases: CounterAdminTabsMixin, PermissionRequiredMixin, SuccessMessageMixin, FormView

get_context_data(**kwargs)

Add sums to the context.

Source code in counter/views/invoice.py
def get_context_data(self, **kwargs):
    """Add sums to the context."""
    kwargs = super().get_context_data(**kwargs)
    kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC")
    month = self.get_month()
    start_date = datetime(month.year, month.month, month.day, tzinfo=timezone.utc)
    end_date = start_date + relativedelta(months=1)

    kwargs["sum_cb"] = Refilling.objects.filter(
        payment_method=Refilling.PaymentMethod.CARD,
        date__gte=start_date,
        date__lte=end_date,
    ).aggregate(res=Sum("amount", default=0))["res"]
    kwargs["sum_cb"] += (
        Selling.objects.filter(
            payment_method=Selling.PaymentMethod.CARD,
            date__gte=start_date,
            date__lte=end_date,
        )
        .annotate(amount=F("unit_price") * F("quantity"))
        .aggregate(res=Sum("amount", default=0))["res"]
    )
    kwargs["start_date"] = start_date
    kwargs["invoices"] = (
        Selling.objects.filter(date__gte=start_date, date__lt=end_date)
        .values("club_id", "club__name")
        .annotate(selling_sum=Sum(F("unit_price") * F("quantity")))
        .exclude(selling_sum=None)
        .order_by("-selling_sum")
    )
    return kwargs

FragmentMixin

Bases: TemplateResponseMixin, AllowFragment, 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.

StudentCardForm

Bases: ModelForm

Form for adding student cards

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.

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"],
    )

can_buy()

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

Source code in counter/models.py
@cached_property
def can_buy(self) -> bool:
    """Check if whether this customer has the right to purchase any item."""
    subscription_end = self.user.subscriptions.aggregate(
        res=Max("subscription_end")
    ).get("res")
    if subscription_end is None:
        return False
    return (date.today() - subscription_end) < timedelta(days=90)

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

StudentCard

Bases: Model

Alternative way to connect a customer into a counter.

We are using Mifare DESFire EV1 specs since it's used for izly cards https://www.nxp.com/docs/en/application-note/AN10927.pdf UID is 7 byte long that means 14 hexa characters.

StudentCardDeleteView

Bases: DeleteView

View used to delete a card from a user. This is a fragment view !

StudentCardFormFragment

Bases: FragmentMixin, FormView

Add a new student card.

can_edit(obj, user)

Can the user edit the object.

Parameters:

Name Type Description Default
obj Any

Object to test for permission

required
user User

core.models.User to test permissions against

required

Returns:

Type Description
bool

True if user is authorized to edit object else False

Example
if not can_edit(self.object, request.user):
    raise PermissionDenied
Source code in core/auth/mixins.py
def can_edit(obj: Any, user: User) -> bool:
    """Can the user edit the object.

    Args:
        obj: Object to test for permission
        user: core.models.User to test permissions against

    Returns:
        True if user is authorized to edit object else False

    Example:
        ```python
        if not can_edit(self.object, request.user):
            raise PermissionDenied
        ```
    """
    if obj is None or user.can_edit(obj):
        return True
    return can_edit_prop(obj, user)

is_logged_in_counter(request, counter=None)

Check if the request is sent from a device logged to a counter.

The request must also be sent within the frame of a counter's activity. Trying to use this function to manage access to non-sas related resources probably won't work.

A request is considered as coming from a logged counter if :

  • Its referer comes from the counter app (eg. fetching user pictures from the click UI) or the request path belongs to the counter app (eg. the barman went back to the main by missclick and go back to the counter)
  • There are barmen logged in the current session
Source code in counter/utils.py
def is_logged_in_counter(request: HttpRequest, counter: Counter | None = None) -> bool:
    """Check if the request is sent from a device logged to a counter.

    The request must also be sent within the frame of a counter's activity.
    Trying to use this function to manage access to non-sas
    related resources probably won't work.

    A request is considered as coming from a logged counter if :

    - Its referer comes from the counter app
      (eg. fetching user pictures from the click UI)
      or the request path belongs to the counter app
      (eg. the barman went back to the main by missclick and go back
      to the counter)
    - There are barmen logged in the current session
    """
    referer_ok = (
        "HTTP_REFERER" in request.META
        and resolve(urlparse(request.META["HTTP_REFERER"]).path).app_name == "counter"
    )
    if not referer_ok and request.resolver_match.app_name != "counter":
        return False

    if counter is None:
        return bool(request.barmen)
    return bool(request.barmen) and request.barmen.issubset(set(counter.barmen_list))