Sith/counter/models.py

1083 lines
38 KiB
Python
Raw Normal View History

#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
2024-09-23 08:25:27 +00:00
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
2022-11-23 11:23:17 +00:00
from __future__ import annotations
2024-06-24 11:07:36 +00:00
import base64
import os
import random
import string
2024-07-04 08:19:24 +00:00
from datetime import date, datetime, timedelta
from datetime import timezone as tz
2024-06-26 10:28:00 +00:00
from typing import Tuple
2024-06-24 11:07:36 +00:00
from dict2xml import dict2xml
2016-03-28 12:54:35 +00:00
from django.conf import settings
2018-10-18 23:21:57 +00:00
from django.core.validators import MinLengthValidator
2024-06-24 11:07:36 +00:00
from django.db import models
from django.db.models import Exists, F, OuterRef, QuerySet, Sum, Value
from django.db.models.functions import Concat, Length
2016-08-01 22:32:55 +00:00
from django.forms import ValidationError
2024-06-24 11:07:36 +00:00
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
2024-06-24 11:07:36 +00:00
from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField
2024-09-26 15:55:53 +00:00
from phonenumber_field.modelfields import PhoneNumberField
2016-03-28 12:54:35 +00:00
2024-06-24 11:07:36 +00:00
from accounting.models import CurrencyField
from club.models import Club
2024-09-14 19:51:35 +00:00
from core.fields import ResizedImageField
2024-06-24 11:07:36 +00:00
from core.models import Group, Notification, User
from core.utils import get_start_of_semester
2022-12-10 19:41:35 +00:00
from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB
2016-12-10 00:58:30 +00:00
from subscription.models import Subscription
2016-03-28 12:54:35 +00:00
2017-06-12 07:47:24 +00:00
class Customer(models.Model):
2024-07-12 07:34:16 +00:00
"""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.
"""
2018-10-04 19:29:19 +00:00
user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE)
2018-10-04 19:29:19 +00:00
account_id = models.CharField(_("account id"), max_length=10, unique=True)
2022-11-23 11:23:17 +00:00
amount = CurrencyField(_("amount"), default=0)
2018-10-04 19:29:19 +00:00
recorded_products = models.IntegerField(_("recorded product"), default=0)
class Meta:
2018-10-04 19:29:19 +00:00
verbose_name = _("customer")
verbose_name_plural = _("customers")
ordering = ["account_id"]
def __str__(self):
2016-08-14 17:28:14 +00:00
return "%s - %s" % (self.user.username, self.account_id)
def save(self, *args, allow_negative=False, is_selling=False, **kwargs):
2024-07-12 07:34:16 +00:00
"""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
2024-07-12 07:34:16 +00:00
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)
def get_absolute_url(self):
return reverse("core:user_account", kwargs={"user_id": self.user.pk})
2017-07-21 19:39:49 +00:00
@property
def can_record(self):
2017-08-15 00:09:44 +00:00
return self.recorded_products > -settings.SITH_ECOCUP_LIMIT
2017-07-21 19:39:49 +00:00
def can_record_more(self, number):
2017-08-15 00:09:44 +00:00
return self.recorded_products - number >= -settings.SITH_ECOCUP_LIMIT
2017-07-21 19:39:49 +00:00
@property
2022-09-25 19:29:42 +00:00
def can_buy(self) -> bool:
2024-07-12 07:34:16 +00:00
"""Check if whether this customer has the right to purchase any item.
2022-09-25 19:29:42 +00:00
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.
"""
2023-03-04 14:01:08 +00:00
subscription = self.user.subscriptions.order_by("subscription_end").last()
if subscription is None:
return False
return (date.today() - subscription.subscription_end) < timedelta(days=90)
@classmethod
2022-11-23 11:23:17 +00:00
def get_or_create(cls, user: User) -> Tuple[Customer, bool]:
2024-07-12 07:34:16 +00:00
"""Work in pretty much the same way as the usual get_or_create method,
2022-11-23 11:23:17 +00:00
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"
"""
2022-11-23 11:23:17 +00:00
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
2022-11-23 11:23:17 +00:00
account = cls.objects.create(user=user, account_id="1504a")
return account, True
account_id = account_id["account_id"]
2022-11-23 11:23:17 +00:00
account_num = int(account_id[:-1])
while Customer.objects.filter(account_id=account_id).exists():
2022-11-23 11:23:17 +00:00
# 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)}"
2022-11-23 11:23:17 +00:00
account = cls.objects.create(user=user, account_id=account_id)
return account, True
2016-09-21 12:09:16 +00:00
def recompute_amount(self):
2023-03-04 14:01:08 +00:00
refillings = self.refillings.aggregate(sum=Sum(F("amount")))["sum"]
self.amount = refillings if refillings is not None else 0
purchases = (
self.buyings.filter(payment_method="SITH_ACCOUNT")
.annotate(amount=F("quantity") * F("unit_price"))
.aggregate(sum=Sum(F("amount")))
)["sum"]
if purchases is not None:
self.amount -= purchases
self.save()
2016-09-21 12:09:16 +00:00
def get_full_url(self):
2018-10-04 19:29:19 +00:00
return "".join(["https://", settings.SITH_URL, self.get_absolute_url()])
class BillingInfo(models.Model):
2024-07-12 07:34:16 +00:00
"""Represent the billing information of a user, which are required
by the 3D-Secure v2 system used by the etransaction module.
"""
customer = models.OneToOneField(
Customer, related_name="billing_infos", on_delete=models.CASCADE
)
# declaring surname and name even though they are already defined
# in User add some redundancy, but ensures that the billing infos
# shall stay correct, whatever shenanigans the user commits on its profile
first_name = models.CharField(_("First name"), max_length=22)
last_name = models.CharField(_("Last name"), max_length=22)
address_1 = models.CharField(_("Address 1"), max_length=50)
address_2 = models.CharField(_("Address 2"), max_length=50, blank=True, null=True)
zip_code = models.CharField(_("Zip code"), max_length=16) # code postal
city = models.CharField(_("City"), max_length=50)
country = CountryField(blank_label=_("Country"))
2024-09-26 15:55:53 +00:00
# This table was created during the A22 semester.
# However, later on, CA asked for the phone number to be added to the billing info.
# As the table was already created, this new field had to be nullable,
# even tough it is required by the bank and shouldn't be null.
# If one day there is no null phone number remaining,
# please make the field non-nullable.
phone_number = PhoneNumberField(_("Phone number"), null=True, blank=False)
def __str__(self):
return f"{self.first_name} {self.last_name}"
def to_3dsv2_xml(self) -> str:
2024-07-12 07:34:16 +00:00
"""Convert the data from this model into a xml usable
by the online paying service of the Crédit Agricole bank.
2024-07-12 07:34:16 +00:00
see : `https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/manuel-dintegration-focus-3ds-v2/principes-generaux/#integration-3dsv2-developpeur-webmaster`.
"""
data = {
2022-12-10 19:41:35 +00:00
"Address": {
"FirstName": self.first_name,
"LastName": self.last_name,
"Address1": self.address_1,
"ZipCode": self.zip_code,
"City": self.city,
"CountryCode": self.country.numeric, # ISO-3166-1 numeric code
2024-09-26 15:55:53 +00:00
"MobilePhone": self.phone_number.as_national.replace(" ", ""),
"CountryCodeMobilePhone": f"+{self.phone_number.country_code}",
}
}
if self.address_2:
2022-12-10 19:41:35 +00:00
data["Address"]["Address2"] = self.address_2
xml = dict2xml(data, wrap="Billing", newlines=False)
return '<?xml version="1.0" encoding="UTF-8" ?>' + xml
class ProductType(models.Model):
2024-07-12 07:34:16 +00:00
"""A product type.
Useful only for categorizing.
"""
2018-10-04 19:29:19 +00:00
name = models.CharField(_("name"), max_length=30)
description = models.TextField(_("description"), null=True, blank=True)
comment = models.TextField(_("comment"), null=True, blank=True)
2024-09-14 19:51:35 +00:00
icon = ResizedImageField(
height=70, force_format="WEBP", upload_to="products", null=True, blank=True
)
2022-11-16 19:41:24 +00:00
# priority holds no real backend logic but helps to handle the order in which
# the items are to be shown to the user
priority = models.PositiveIntegerField(default=0)
2016-07-27 18:05:45 +00:00
class Meta:
2018-10-04 19:29:19 +00:00
verbose_name = _("product type")
2022-11-16 19:41:24 +00:00
ordering = ["-priority", "name"]
2016-07-27 18:05:45 +00:00
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("counter:producttype_list")
def is_owned_by(self, user):
2024-07-12 07:34:16 +00:00
"""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):
return True
return False
2017-06-12 07:47:24 +00:00
class Product(models.Model):
2024-07-12 07:34:16 +00:00
"""A product, with all its related information."""
2018-10-04 19:29:19 +00:00
name = models.CharField(_("name"), max_length=64)
description = models.TextField(_("description"), blank=True)
product_type = models.ForeignKey(
ProductType,
related_name="products",
verbose_name=_("product type"),
null=True,
blank=True,
on_delete=models.SET_NULL,
)
code = models.CharField(_("code"), max_length=16, blank=True)
purchase_price = CurrencyField(_("purchase price"))
selling_price = CurrencyField(_("selling price"))
special_selling_price = CurrencyField(_("special selling price"))
2024-09-14 19:51:35 +00:00
icon = ResizedImageField(
height=70,
force_format="WEBP",
upload_to="products",
null=True,
blank=True,
verbose_name=_("icon"),
2018-10-04 19:29:19 +00:00
)
club = models.ForeignKey(
Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE
)
2018-10-04 19:29:19 +00:00
limit_age = models.IntegerField(_("limit age"), default=0)
tray = models.BooleanField(_("tray price"), default=False)
parent_product = models.ForeignKey(
"self",
related_name="children_products",
verbose_name=_("parent product"),
null=True,
blank=True,
on_delete=models.SET_NULL,
)
buying_groups = models.ManyToManyField(
Group, related_name="products", verbose_name=_("buying groups"), blank=True
)
archived = models.BooleanField(_("archived"), default=False)
2016-07-27 18:05:45 +00:00
class Meta:
2018-10-04 19:29:19 +00:00
verbose_name = _("product")
2016-07-27 18:05:45 +00:00
def __str__(self):
return "%s (%s)" % (self.name, self.code)
def get_absolute_url(self):
return reverse("counter:product_list")
2017-07-21 19:39:49 +00:00
@property
def is_record_product(self):
2017-08-15 00:09:44 +00:00
return settings.SITH_ECOCUP_CONS == self.id
2017-07-21 19:39:49 +00:00
@property
def is_unrecord_product(self):
2017-08-15 00:09:44 +00:00
return settings.SITH_ECOCUP_DECO == self.id
2017-07-21 19:39:49 +00:00
def is_owned_by(self, user):
2024-07-12 07:34:16 +00:00
"""Method to see if that object can be edited by the given user."""
if user.is_anonymous:
return False
2018-10-04 19:29:19 +00:00
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
2022-09-25 19:29:42 +00:00
def can_be_sold_to(self, user: User) -> bool:
2024-07-12 07:34:16 +00:00
"""Check if whether the user given in parameter has the right to buy
2022-09-25 19:29:42 +00:00
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).
2024-07-12 07:34:16 +00:00
Returns:
True if the user can buy this product else False
2024-07-27 22:09:39 +00:00
Warning:
2024-07-27 22:09:39 +00:00
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)
]
```
2022-09-25 19:29:42 +00:00
"""
2024-07-27 22:09:39 +00:00
buying_groups = list(self.buying_groups.all())
if not buying_groups:
2022-09-25 19:29:42 +00:00
return True
2024-07-27 22:09:39 +00:00
for group in buying_groups:
if user.is_in_group(pk=group.id):
2022-09-25 19:29:42 +00:00
return True
return False
2022-12-19 19:55:33 +00:00
@property
def profit(self):
return self.selling_price - self.purchase_price
2017-06-12 07:47:24 +00:00
class CounterQuerySet(models.QuerySet):
def annotate_has_barman(self, user: User) -> CounterQuerySet:
2024-07-12 07:34:16 +00:00
"""Annotate the queryset with the `user_is_barman` field.
For each counter, this field has value True if the user
is a barman of this counter, else False.
2024-07-12 07:34:16 +00:00
Args:
user: the user we want to check if he is a barman
2024-07-12 07:34:16 +00:00
Examples:
```python
sli = User.objects.get(username="sli")
counters = (
Counter.objects
.annotate_has_barman(sli) # add the user_has_barman boolean field
.filter(has_annotated_barman=True) # keep only counters where this user is barman
)
print("Sli est barman dans les comptoirs suivants :")
for counter in counters:
print(f"- {counter.name}")
2024-07-12 07:34:16 +00:00
```
"""
subquery = user.counters.filter(pk=OuterRef("pk"))
return self.annotate(has_annotated_barman=Exists(subquery))
2016-03-28 12:54:35 +00:00
class Counter(models.Model):
2018-10-04 19:29:19 +00:00
name = models.CharField(_("name"), max_length=30)
club = models.ForeignKey(
Club, related_name="counters", verbose_name=_("club"), on_delete=models.CASCADE
)
2018-10-04 19:29:19 +00:00
products = models.ManyToManyField(
Product, related_name="counters", verbose_name=_("products"), blank=True
)
type = models.CharField(
_("counter type"),
max_length=255,
choices=[("BAR", _("Bar")), ("OFFICE", _("Office")), ("EBOUTIC", _("Eboutic"))],
)
sellers = models.ManyToManyField(
User, verbose_name=_("sellers"), related_name="counters", blank=True
)
edit_groups = models.ManyToManyField(
Group, related_name="editable_counters", blank=True
)
view_groups = models.ManyToManyField(
Group, related_name="viewable_counters", blank=True
)
token = models.CharField(_("token"), max_length=30, null=True, blank=True)
2016-03-28 12:54:35 +00:00
objects = CounterQuerySet.as_manager()
2016-07-27 18:05:45 +00:00
class Meta:
2018-10-04 19:29:19 +00:00
verbose_name = _("counter")
2016-07-27 18:05:45 +00:00
2016-03-28 12:54:35 +00:00
def __str__(self):
return self.name
2016-03-29 08:30:24 +00:00
2024-07-21 08:44:43 +00:00
def get_absolute_url(self) -> str:
2016-07-22 11:34:34 +00:00
if self.type == "EBOUTIC":
2018-10-04 19:29:19 +00:00
return reverse("eboutic:main")
return reverse("counter:details", kwargs={"counter_id": self.id})
2016-03-29 08:30:24 +00:00
2024-07-21 08:44:43 +00:00
def __getattribute__(self, name: str):
if name == "edit_groups":
return Group.objects.filter(
name=self.club.unix_name + settings.SITH_BOARD_SUFFIX
).all()
return object.__getattribute__(self, name)
2024-07-21 08:44:43 +00:00
def is_owned_by(self, user: User) -> bool:
if user.is_anonymous:
return False
mem = self.club.get_membership_for(user)
if mem and mem.role >= 7:
return True
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
2024-07-21 08:44:43 +00:00
def can_be_viewed_by(self, user: User) -> bool:
2017-01-04 18:39:37 +00:00
if self.type == "BAR":
return True
return user.is_board_member or user in self.sellers.all()
2024-07-21 08:44:43 +00:00
def gen_token(self) -> None:
2024-07-12 07:34:16 +00:00
"""Generate a new token for this counter."""
2018-10-04 19:29:19 +00:00
self.token = "".join(
2024-07-21 08:44:43 +00:00
random.choice(string.ascii_letters + string.digits) for _ in range(30)
2018-10-04 19:29:19 +00:00
)
self.save()
@cached_property
2024-07-21 08:44:43 +00:00
def barmen_list(self) -> list[User]:
return self.get_barmen_list()
2024-07-21 08:44:43 +00:00
def get_barmen_list(self) -> list[User]:
2024-07-12 07:34:16 +00:00
"""Returns the barman list as list of User.
2016-07-18 11:22:50 +00:00
Also handle the timeout of the barmen
"""
2024-07-21 08:44:43 +00:00
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")]
2024-07-21 08:44:43 +00:00
def get_random_barman(self) -> User:
2024-07-12 07:34:16 +00:00
"""Return a random user being currently a barman."""
2024-07-21 08:44:43 +00:00
return random.choice(self.barmen_list)
2016-05-31 17:32:15 +00:00
2024-07-21 08:44:43 +00:00
def update_activity(self) -> None:
2024-07-12 07:34:16 +00:00
"""Update the barman activity to prevent timeout."""
2024-07-21 08:44:43 +00:00
self.permanencies.filter(end=None).update(activity=timezone.now())
2016-09-12 15:34:33 +00:00
2024-07-21 08:44:43 +00:00
@property
def is_open(self) -> bool:
return len(self.barmen_list) > 0
2016-08-05 18:01:23 +00:00
2024-07-21 08:44:43 +00:00
def is_inactive(self) -> bool:
2024-07-12 07:34:16 +00:00
"""Returns True if the counter self is inactive from SITH_COUNTER_MINUTE_INACTIVE's value minutes, else False."""
2024-07-21 08:44:43 +00:00
return self.is_open and (
2018-10-04 19:29:19 +00:00
(timezone.now() - self.permanencies.order_by("-activity").first().activity)
> timedelta(minutes=settings.SITH_COUNTER_MINUTE_INACTIVE)
2018-10-04 19:29:19 +00:00
)
2016-10-16 16:52:04 +00:00
2024-07-21 08:44:43 +00:00
def barman_list(self) -> list[int]:
2024-07-12 07:34:16 +00:00
"""Returns the barman id list."""
2024-07-21 08:44:43 +00:00
return [b.id for b in self.barmen_list]
2016-08-06 10:37:36 +00:00
2024-07-21 08:44:43 +00:00
def can_refill(self) -> bool:
2024-07-12 07:34:16 +00:00
"""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
2024-07-21 08:44:43 +00:00
# at least one of the barmen is in the AE board
ae = Club.objects.get(unix_name=SITH_MAIN_CLUB["unix_name"])
2024-07-21 08:44:43 +00:00
return any(ae.get_membership_for(barman) for barman in self.barmen_list)
2022-04-20 12:01:33 +00:00
def get_top_barmen(self) -> QuerySet:
2024-07-12 07:34:16 +00:00
"""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")
)
2024-06-26 10:28:00 +00:00
def get_top_customers(self, since: datetime | date | None = None) -> QuerySet:
2024-07-12 07:34:16 +00:00
"""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 :
2024-06-26 10:28:00 +00:00
2024-07-12 07:34:16 +00:00
- 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()
2024-06-26 10:28:00 +00:00
if isinstance(since, date):
2024-07-04 08:19:24 +00:00
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")
)
2024-06-26 10:28:00 +00:00
def get_total_sales(self, since: datetime | date | None = None) -> CurrencyField:
2024-07-12 07:34:16 +00:00
"""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):
2024-07-04 08:19:24 +00:00
since = datetime(since.year, since.month, since.day, tzinfo=tz.utc)
2024-07-21 08:44:43 +00:00
return self.sellings.filter(date__gte=since).aggregate(
total=Sum(
F("quantity") * F("unit_price"),
default=0,
output_field=CurrencyField(),
)
)["total"]
2017-06-12 07:47:24 +00:00
2016-05-31 17:32:15 +00:00
class Refilling(models.Model):
2024-07-12 07:34:16 +00:00
"""Handle the refilling."""
2018-10-04 19:29:19 +00:00
counter = models.ForeignKey(
Counter, related_name="refillings", blank=False, on_delete=models.CASCADE
)
2018-10-04 19:29:19 +00:00
amount = CurrencyField(_("amount"))
operator = models.ForeignKey(
User,
related_name="refillings_as_operator",
blank=False,
on_delete=models.CASCADE,
)
customer = models.ForeignKey(
Customer, related_name="refillings", blank=False, on_delete=models.CASCADE
2018-10-04 19:29:19 +00:00
)
date = models.DateTimeField(_("date"))
payment_method = models.CharField(
_("payment method"),
max_length=255,
choices=settings.SITH_COUNTER_PAYMENT_METHOD,
default="CASH",
)
bank = models.CharField(
_("bank"), max_length=255, choices=settings.SITH_COUNTER_BANK, default="OTHER"
)
is_validated = models.BooleanField(_("is validated"), default=False)
2016-05-31 17:32:15 +00:00
2016-07-27 18:05:45 +00:00
class Meta:
verbose_name = _("refilling")
2016-05-31 17:32:15 +00:00
def __str__(self):
2018-10-04 19:29:19 +00:00
return "Refilling: %.2f for %s" % (
self.amount,
self.customer.user.get_display_name(),
)
2016-05-31 17:32:15 +00:00
def save(self, *args, **kwargs):
if not self.date:
2016-08-18 19:06:10 +00:00
self.date = timezone.now()
2016-05-31 17:32:15 +00:00
self.full_clean()
2016-08-01 22:32:55 +00:00
if not self.is_validated:
self.customer.amount += self.amount
self.customer.save()
self.is_validated = True
if self.customer.user.preferences.notify_on_refill:
2018-10-04 19:29:19 +00:00
Notification(
user=self.customer.user,
url=reverse(
"core:user_account_detail",
kwargs={
"user_id": self.customer.user.id,
"year": self.date.year,
"month": self.date.month,
},
),
param=str(self.amount),
type="REFILLING",
).save()
2024-06-27 12:46:43 +00:00
super().save(*args, **kwargs)
2016-05-31 17:32:15 +00:00
def is_owned_by(self, user):
if user.is_anonymous:
return False
return user.is_owner(self.counter) and self.payment_method != "CARD"
def delete(self, *args, **kwargs):
self.customer.amount -= self.amount
self.customer.save()
super().delete(*args, **kwargs)
2017-06-12 07:47:24 +00:00
2016-05-31 17:32:15 +00:00
class Selling(models.Model):
2024-07-12 07:34:16 +00:00
"""Handle the sellings."""
2018-10-04 19:29:19 +00:00
label = models.CharField(_("label"), max_length=64)
2018-10-04 19:29:19 +00:00
product = models.ForeignKey(
Product,
related_name="sellings",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
counter = models.ForeignKey(
Counter,
related_name="sellings",
null=True,
blank=False,
on_delete=models.SET_NULL,
)
club = models.ForeignKey(
Club, related_name="sellings", null=True, blank=False, on_delete=models.SET_NULL
)
unit_price = CurrencyField(_("unit price"))
quantity = models.IntegerField(_("quantity"))
seller = models.ForeignKey(
User,
related_name="sellings_as_operator",
null=True,
blank=False,
on_delete=models.SET_NULL,
)
customer = models.ForeignKey(
Customer,
related_name="buyings",
null=True,
blank=False,
on_delete=models.SET_NULL,
)
date = models.DateTimeField(_("date"))
payment_method = models.CharField(
_("payment method"),
max_length=255,
choices=[("SITH_ACCOUNT", _("Sith account")), ("CARD", _("Credit card"))],
default="SITH_ACCOUNT",
)
is_validated = models.BooleanField(_("is validated"), default=False)
2016-05-31 17:32:15 +00:00
2016-07-27 18:05:45 +00:00
class Meta:
verbose_name = _("selling")
2016-05-31 17:32:15 +00:00
def __str__(self):
2018-10-04 19:29:19 +00:00
return "Selling: %d x %s (%f) for %s" % (
self.quantity,
self.label,
self.quantity * self.unit_price,
self.customer.user.get_display_name(),
)
2016-05-31 17:32:15 +00:00
2024-06-27 12:57:40 +00:00
def save(self, *args, allow_negative=False, **kwargs):
2024-07-12 07:34:16 +00:00
"""allow_negative : Allow this selling to use more money than available for this user."""
if not self.date:
2016-08-18 19:06:10 +00:00
self.date = timezone.now()
2016-05-31 17:32:15 +00:00
self.full_clean()
2016-08-01 22:32:55 +00:00
if not self.is_validated:
self.customer.amount -= self.quantity * self.unit_price
2017-08-15 00:09:44 +00:00
self.customer.save(allow_negative=allow_negative, is_selling=True)
2016-08-01 22:32:55 +00:00
self.is_validated = True
2024-07-27 22:09:39 +00:00
user = self.customer.user
if user.was_subscribed:
2018-10-04 19:29:19 +00:00
if (
self.product
and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER
):
sub = Subscription(
2024-07-27 22:09:39 +00:00
member=user,
2018-10-04 19:29:19 +00:00
subscription_type="un-semestre",
2017-06-12 07:47:24 +00:00
payment_method="EBOUTIC",
location="EBOUTIC",
)
sub.subscription_start = Subscription.compute_start()
sub.subscription_start = Subscription.compute_start(
2018-10-04 19:29:19 +00:00
duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][
"duration"
]
)
sub.subscription_end = Subscription.compute_end(
2018-10-04 19:29:19 +00:00
duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][
"duration"
],
start=sub.subscription_start,
)
sub.save()
2018-10-04 19:29:19 +00:00
elif (
self.product
and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS
):
sub = Subscription(
2024-07-27 22:09:39 +00:00
member=user,
2018-10-04 19:29:19 +00:00
subscription_type="deux-semestres",
2017-06-12 07:47:24 +00:00
payment_method="EBOUTIC",
location="EBOUTIC",
)
sub.subscription_start = Subscription.compute_start()
sub.subscription_start = Subscription.compute_start(
2018-10-04 19:29:19 +00:00
duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][
"duration"
]
)
sub.subscription_end = Subscription.compute_end(
2018-10-04 19:29:19 +00:00
duration=settings.SITH_SUBSCRIPTIONS[sub.subscription_type][
"duration"
],
start=sub.subscription_start,
)
sub.save()
2024-07-27 22:09:39 +00:00
if user.preferences.notify_on_click:
Notification(
2024-07-27 22:09:39 +00:00
user=user,
2018-10-04 19:29:19 +00:00
url=reverse(
"core:user_account_detail",
kwargs={
2024-07-27 22:09:39 +00:00
"user_id": user.id,
2018-10-04 19:29:19 +00:00
"year": self.date.year,
"month": self.date.month,
},
),
param="%d x %s" % (self.quantity, self.label),
type="SELLING",
).save()
2024-06-27 12:46:43 +00:00
super().save(*args, **kwargs)
2024-07-27 22:09:39 +00:00
if hasattr(self.product, "eticket"):
self.send_mail_customer()
2019-10-13 15:29:08 +00:00
2024-07-27 22:09:39 +00:00
def is_owned_by(self, user: User) -> bool:
if user.is_anonymous:
return False
2024-07-27 22:09:39 +00:00
return self.payment_method != "CARD" and user.is_owner(self.counter)
2024-07-27 22:09:39 +00:00
def can_be_viewed_by(self, user: User) -> bool:
if (
not hasattr(self, "customer") or self.customer is None
): # Customer can be set to Null
return False
return user == self.customer.user
def delete(self, *args, **kwargs):
if self.payment_method == "SITH_ACCOUNT":
self.customer.amount += self.quantity * self.unit_price
self.customer.save()
super().delete(*args, **kwargs)
def send_mail_customer(self):
event = self.product.eticket.event_title or _("Unknown event")
subject = _("Eticket bought for the event %(event)s") % {"event": event}
message_html = _(
"You bought an eticket for the event %(event)s.\nYou can download it directly from this link %(eticket)s.\nYou can also retrieve all your e-tickets on your account page %(url)s."
) % {
"event": event,
"url": "".join(
(
'<a href="',
self.customer.get_full_url(),
'">',
self.customer.get_full_url(),
"</a>",
)
),
"eticket": "".join(
(
'<a href="',
self.get_eticket_full_url(),
'">',
self.get_eticket_full_url(),
"</a>",
)
),
}
message_txt = _(
"You bought an eticket for the event %(event)s.\nYou can download it directly from this link %(eticket)s.\nYou can also retrieve all your e-tickets on your account page %(url)s."
) % {
"event": event,
"url": self.customer.get_full_url(),
"eticket": self.get_eticket_full_url(),
}
2024-07-27 22:09:39 +00:00
self.customer.user.email_user(
subject, message_txt, html_message=message_html, fail_silently=True
)
2019-10-13 15:29:08 +00:00
def get_eticket_full_url(self):
eticket_url = reverse("counter:eticket_pdf", kwargs={"selling_id": self.id})
2019-10-13 22:32:11 +00:00
return "".join(["https://", settings.SITH_URL, eticket_url])
2016-05-31 17:32:15 +00:00
2017-06-12 07:47:24 +00:00
2016-07-18 11:22:50 +00:00
class Permanency(models.Model):
2024-07-12 07:34:16 +00:00
"""A permanency of a barman, on a counter.
This aims at storing a traceability of who was barman where and when.
Mainly for ~~dick size contest~~ establishing the top 10 barmen of the semester.
2016-07-18 11:22:50 +00:00
"""
2018-10-04 19:29:19 +00:00
user = models.ForeignKey(
User,
related_name="permanencies",
verbose_name=_("user"),
on_delete=models.CASCADE,
)
2018-10-04 19:29:19 +00:00
counter = models.ForeignKey(
Counter,
related_name="permanencies",
verbose_name=_("counter"),
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
start = models.DateTimeField(_("start date"))
end = models.DateTimeField(_("end date"), null=True, db_index=True)
activity = models.DateTimeField(_("last activity date"), auto_now=True)
2016-07-18 11:22:50 +00:00
2016-07-27 18:05:45 +00:00
class Meta:
verbose_name = _("permanency")
2016-07-18 11:22:50 +00:00
def __str__(self):
2018-10-04 19:29:19 +00:00
return "%s in %s from %s (last activity: %s) to %s" % (
self.user,
self.counter,
self.start.strftime("%Y-%m-%d %H:%M:%S"),
self.activity.strftime("%Y-%m-%d %H:%M:%S"),
self.end.strftime("%Y-%m-%d %H:%M:%S") if self.end else "",
)
2017-06-12 07:47:24 +00:00
2022-12-19 19:55:33 +00:00
@property
def duration(self):
if self.end is None:
return self.activity - self.start
return self.end - self.start
2016-07-18 11:22:50 +00:00
2016-08-26 18:57:04 +00:00
class CashRegisterSummary(models.Model):
2018-10-04 19:29:19 +00:00
user = models.ForeignKey(
User,
related_name="cash_summaries",
verbose_name=_("user"),
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
counter = models.ForeignKey(
Counter,
related_name="cash_summaries",
verbose_name=_("counter"),
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
date = models.DateTimeField(_("date"))
comment = models.TextField(_("comment"), null=True, blank=True)
emptied = models.BooleanField(_("emptied"), default=False)
2016-08-26 18:57:04 +00:00
class Meta:
verbose_name = _("cash register summary")
def __str__(self):
return "At %s by %s - Total: %s" % (self.counter, self.user, self.get_total())
def save(self, *args, **kwargs):
if not self.id:
self.date = timezone.now()
return super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse("counter:cash_summary_list")
def __getattribute__(self, name):
2018-10-04 19:29:19 +00:00
if name[:5] == "check":
checks = self.items.filter(check=True).order_by("value").all()
if name == "ten_cents":
return self.items.filter(value=0.1, is_check=False).first()
2018-10-04 19:29:19 +00:00
elif name == "twenty_cents":
return self.items.filter(value=0.2, is_check=False).first()
2018-10-04 19:29:19 +00:00
elif name == "fifty_cents":
return self.items.filter(value=0.5, is_check=False).first()
2018-10-04 19:29:19 +00:00
elif name == "one_euro":
return self.items.filter(value=1, is_check=False).first()
2018-10-04 19:29:19 +00:00
elif name == "two_euros":
return self.items.filter(value=2, is_check=False).first()
2018-10-04 19:29:19 +00:00
elif name == "five_euros":
return self.items.filter(value=5, is_check=False).first()
2018-10-04 19:29:19 +00:00
elif name == "ten_euros":
return self.items.filter(value=10, is_check=False).first()
2018-10-04 19:29:19 +00:00
elif name == "twenty_euros":
return self.items.filter(value=20, is_check=False).first()
2018-10-04 19:29:19 +00:00
elif name == "fifty_euros":
return self.items.filter(value=50, is_check=False).first()
2018-10-04 19:29:19 +00:00
elif name == "hundred_euros":
return self.items.filter(value=100, is_check=False).first()
2018-10-04 19:29:19 +00:00
elif name == "check_1":
return checks[0] if 0 < len(checks) else None
2018-10-04 19:29:19 +00:00
elif name == "check_2":
return checks[1] if 1 < len(checks) else None
2018-10-04 19:29:19 +00:00
elif name == "check_3":
return checks[2] if 2 < len(checks) else None
2018-10-04 19:29:19 +00:00
elif name == "check_4":
return checks[3] if 3 < len(checks) else None
2018-10-04 19:29:19 +00:00
elif name == "check_5":
return checks[4] if 4 < len(checks) else None
else:
return object.__getattribute__(self, name)
2016-09-13 00:04:49 +00:00
def is_owned_by(self, user):
2024-07-12 07:34:16 +00:00
"""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_COUNTER_ADMIN_ID):
2016-09-13 00:04:49 +00:00
return True
return False
2016-08-26 18:57:04 +00:00
def get_total(self):
t = 0
for it in self.items.all():
t += it.quantity * it.value
return t
2017-06-12 07:47:24 +00:00
2016-08-26 18:57:04 +00:00
class CashRegisterSummaryItem(models.Model):
2018-10-04 19:29:19 +00:00
cash_summary = models.ForeignKey(
CashRegisterSummary,
related_name="items",
verbose_name=_("cash summary"),
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
2016-08-26 18:57:04 +00:00
value = CurrencyField(_("value"))
2018-10-04 19:29:19 +00:00
quantity = models.IntegerField(_("quantity"), default=0)
is_check = models.BooleanField(
_("check"),
default=False,
help_text=_("True if this is a bank check, else False"),
)
2016-08-26 18:57:04 +00:00
class Meta:
verbose_name = _("cash register summary item")
def __str__(self):
return str(self.value)
2017-06-12 07:47:24 +00:00
2016-10-03 17:30:05 +00:00
class Eticket(models.Model):
2024-07-12 07:34:16 +00:00
"""Eticket can be linked to a product an allows PDF generation."""
2018-10-04 19:29:19 +00:00
product = models.OneToOneField(
Product,
related_name="eticket",
verbose_name=_("product"),
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
banner = models.ImageField(
upload_to="etickets", null=True, blank=True, verbose_name=_("banner")
)
event_date = models.DateField(_("event date"), null=True, blank=True)
event_title = models.CharField(
_("event title"), max_length=64, null=True, blank=True
)
secret = models.CharField(_("secret"), max_length=64, unique=True)
2016-10-03 17:30:05 +00:00
def __str__(self):
return self.product.name
2016-10-03 17:30:05 +00:00
def save(self, *args, **kwargs):
if not self.id:
self.secret = base64.b64encode(os.urandom(32))
2024-06-27 12:46:43 +00:00
return super().save(*args, **kwargs)
2016-10-03 17:30:05 +00:00
def get_absolute_url(self):
return reverse("counter:eticket_list")
2016-10-03 17:30:05 +00:00
def is_owned_by(self, user):
2024-07-12 07:34:16 +00:00
"""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)
2016-10-03 17:30:05 +00:00
def get_hash(self, string):
2017-06-12 07:47:24 +00:00
import hashlib
import hmac
2018-10-04 19:29:19 +00:00
return hmac.new(
bytes(self.secret, "utf-8"), bytes(string, "utf-8"), hashlib.sha1
).hexdigest()
class StudentCard(models.Model):
2024-07-12 07:34:16 +00:00
"""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
2024-07-12 07:34:16 +00:00
UID is 7 byte long that means 14 hexa characters.
"""
UID_SIZE = 14
uid = models.CharField(
_("uid"), max_length=UID_SIZE, unique=True, validators=[MinLengthValidator(4)]
)
customer = models.ForeignKey(
Customer,
related_name="student_cards",
verbose_name=_("student cards"),
null=False,
blank=False,
on_delete=models.CASCADE,
)
def __str__(self):
return self.uid
@staticmethod
def is_valid(uid):
return (
(uid.isupper() or uid.isnumeric())
and len(uid) == StudentCard.UID_SIZE
and not StudentCard.objects.filter(uid=uid).exists()
)
@staticmethod
def can_create(customer, user):
return user.pk == customer.user.pk or user.is_board_member or user.is_root
def can_be_edited_by(self, obj):
if isinstance(obj, User):
return StudentCard.can_create(self.customer, obj)
return False