Merge pull request #477 from imperosol/eboutic

Refonte de la boutique en ligne
This commit is contained in:
thomas girod 2022-10-31 16:28:56 +01:00 committed by GitHub
commit d3c115e3f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1514 additions and 761 deletions

View File

@ -16,3 +16,4 @@ Zar <antoine.charmeau@utbm.fr> <antoine.charmeau@laposte.net>
root <root@localhost.localdomain> root <root@localhost.localdomain>
tleb <tleb@openmailbox.org> <theo.lebrun@live.fr> tleb <tleb@openmailbox.org> <theo.lebrun@live.fr>
tleb <tleb@openmailbox.org> <theo.lebrun@utbm.fr> tleb <tleb@openmailbox.org> <theo.lebrun@utbm.fr>
Maréchal <thgirod@hotmail.com>

View File

@ -505,6 +505,8 @@ Welcome to the wiki page!
refound.save() refound.save()
# Counters # Counters
subscribers = Group.objects.get(name="Subscribers")
old_subscribers = Group.objects.get(name="Old subscribers")
Customer(user=skia, account_id="6568j", amount=0).save() Customer(user=skia, account_id="6568j", amount=0).save()
Customer(user=r, account_id="4000k", amount=0).save() Customer(user=r, account_id="4000k", amount=0).save()
p = ProductType(name="Bières bouteilles") p = ProductType(name="Bières bouteilles")
@ -525,6 +527,9 @@ Welcome to the wiki page!
club=main_club, club=main_club,
) )
cotis.save() cotis.save()
cotis.buying_groups.add(subscribers)
cotis.buying_groups.add(old_subscribers)
cotis.save()
cotis2 = Product( cotis2 = Product(
name="Cotis 2 semestres", name="Cotis 2 semestres",
code="2SCOTIZ", code="2SCOTIZ",
@ -535,6 +540,9 @@ Welcome to the wiki page!
club=main_club, club=main_club,
) )
cotis2.save() cotis2.save()
cotis2.buying_groups.add(subscribers)
cotis2.buying_groups.add(old_subscribers)
cotis2.save()
refill = Product( refill = Product(
name="Rechargement 15 €", name="Rechargement 15 €",
code="15REFILL", code="15REFILL",
@ -545,6 +553,8 @@ Welcome to the wiki page!
club=main_club, club=main_club,
) )
refill.save() refill.save()
refill.buying_groups.add(subscribers)
refill.save()
barb = Product( barb = Product(
name="Barbar", name="Barbar",
code="BARB", code="BARB",
@ -553,8 +563,11 @@ Welcome to the wiki page!
selling_price="1.7", selling_price="1.7",
special_selling_price="1.6", special_selling_price="1.6",
club=main_club, club=main_club,
limit_age=18,
) )
barb.save() barb.save()
barb.buying_groups.add(subscribers)
barb.save()
cble = Product( cble = Product(
name="Chimay Bleue", name="Chimay Bleue",
code="CBLE", code="CBLE",
@ -563,8 +576,11 @@ Welcome to the wiki page!
selling_price="1.7", selling_price="1.7",
special_selling_price="1.6", special_selling_price="1.6",
club=main_club, club=main_club,
limit_age=18,
) )
cble.save() cble.save()
cble.buying_groups.add(subscribers)
cble.save()
cons = Product( cons = Product(
name="Consigne Eco-cup", name="Consigne Eco-cup",
code="CONS", code="CONS",
@ -574,7 +590,6 @@ Welcome to the wiki page!
special_selling_price="1", special_selling_price="1",
club=main_club, club=main_club,
) )
cons.id = 1152
cons.save() cons.save()
dcons = Product( dcons = Product(
name="Déconsigne Eco-cup", name="Déconsigne Eco-cup",
@ -585,9 +600,8 @@ Welcome to the wiki page!
special_selling_price="-1", special_selling_price="-1",
club=main_club, club=main_club,
) )
dcons.id = 1151
dcons.save() dcons.save()
Product( cors = Product(
name="Corsendonk", name="Corsendonk",
code="CORS", code="CORS",
product_type=p, product_type=p,
@ -595,8 +609,12 @@ Welcome to the wiki page!
selling_price="1.7", selling_price="1.7",
special_selling_price="1.6", special_selling_price="1.6",
club=main_club, club=main_club,
).save() limit_age=18,
Product( )
cors.save()
cors.buying_groups.add(subscribers)
cors.save()
carolus = Product(
name="Carolus", name="Carolus",
code="CARO", code="CARO",
product_type=p, product_type=p,
@ -604,7 +622,11 @@ Welcome to the wiki page!
selling_price="1.7", selling_price="1.7",
special_selling_price="1.6", special_selling_price="1.6",
club=main_club, club=main_club,
).save() limit_age=18,
)
carolus.save()
carolus.buying_groups.add(subscribers)
carolus.save()
mde = Counter.objects.filter(name="MDE").first() mde = Counter.objects.filter(name="MDE").first()
mde.products.add(barb) mde.products.add(barb)
mde.products.add(cble) mde.products.add(cble)

View File

@ -325,6 +325,13 @@ class User(AbstractBaseUser):
) )
return s.exists() return s.exists()
@cached_property
def account_balance(self):
if hasattr(self, "customer"):
return self.customer.amount
else:
return 0
_club_memberships = {} _club_memberships = {}
_group_names = {} _group_names = {}
_group_ids = {} _group_ids = {}
@ -459,6 +466,20 @@ class User(AbstractBaseUser):
def is_banned_counter(self): def is_banned_counter(self):
return self.is_in_group(settings.SITH_GROUP_BANNED_COUNTER_ID) return self.is_in_group(settings.SITH_GROUP_BANNED_COUNTER_ID)
@cached_property
def age(self) -> int:
"""
Return the age this user has the day the method is called.
"""
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
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
create = False create = False
with transaction.atomic(): with transaction.atomic():

5
core/static/core/js/alpinejs.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -159,9 +159,9 @@ a {
header { header {
width: 90%; width: 90%;
margin: 0px auto; margin: 0 auto;
display: flex; display: flex;
box-shadow: $shadow-color 0px 0px 15px; box-shadow: $shadow-color 0 0 15px;
border-top: none; border-top: none;
//background-color: $primary-neutral-dark-color; //background-color: $primary-neutral-dark-color;
border-radius: 0px 0px 10px 10px; border-radius: 0px 0px 10px 10px;
@ -196,7 +196,7 @@ header {
} }
#header_connect_links { #header_connect_links {
margin: 0.6em 0.6em 0em auto; margin: 0.6em 0.6em 0 auto;
padding: 0.2em; padding: 0.2em;
color: $white-color; color: $white-color;
form { form {
@ -221,7 +221,7 @@ header {
a { a {
text-decoration: none; text-decoration: none;
margin: 0em 1em; margin: 0 1em;
font-weight: bold; font-weight: bold;
color: $white-color; color: $white-color;
&:hover { &:hover {
@ -247,7 +247,7 @@ header {
#header_search { #header_search {
display: inline-block; display: inline-block;
flex: auto; flex: auto;
margin: 0.8em 0em; margin: 0.8em 0;
input { input {
width: 14ch; width: 14ch;
} }
@ -258,10 +258,10 @@ header {
flex: initial; flex: initial;
flex-wrap: wrap; flex-wrap: wrap;
text-align: right; text-align: right;
margin: 0em; margin: 0;
div { div {
display: inline; display: inline;
padding: 1.2em 0em; padding: 1.2em 0;
&:first-child { &:first-child {
flex: auto; flex: auto;
} }
@ -283,7 +283,7 @@ header {
background: white; background: white;
text-align: left; text-align: left;
font-size: 80%; font-size: 80%;
margin: 1.5em 0em 0em -14em; margin: 1.5em 0 0em -14em;
.header_notif_date { .header_notif_date {
font-weight: bold; font-weight: bold;
} }
@ -291,7 +291,7 @@ header {
color: grey; color: grey;
} }
a { a {
margin: 0em; margin: 0;
color: $black-color; color: $black-color;
&:hover { &:hover {
color: $primary-dark-color; color: $primary-dark-color;
@ -319,7 +319,7 @@ header {
#popupheader { #popupheader {
width: 88%; width: 88%;
margin: 0px auto; margin: 0 auto;
padding: 0.3em 1%; padding: 0.3em 1%;
} }
@ -362,15 +362,15 @@ header {
#page { #page {
width: 90%; width: 90%;
margin: 0em auto; margin: 20px auto 0;
/*---------------------------------NAV---------------------------------*/ /*---------------------------------NAV---------------------------------*/
nav { nav {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
background-color: $primary-dark-color; background-color: $primary-dark-color;
color: $white-color; color: $white-color;
border-radius: 6px 6px 0px 0px; border-radius: 6px 6px 0 0;
box-shadow: $shadow-color 0px 0px 15px; box-shadow: $shadow-color 0 0 15px;
a { a {
flex: auto; flex: auto;
@ -385,10 +385,10 @@ header {
background: $secondary-neutral-color; background: $secondary-neutral-color;
color: $white-color; color: $white-color;
&:first-of-type { &:first-of-type {
border-radius: 6px 0px 0px 0px; border-radius: 6px 0 0 0;
} }
&:last-of-type { &:last-of-type {
border-radius: 0px 6px 0px 0px; border-radius: 0 6px 0 0;
} }
} }
} }
@ -411,7 +411,7 @@ header {
overflow: auto; overflow: auto;
width: 100%; width: 100%;
background-color: #f9f9f9; background-color: #f9f9f9;
box-shadow: 3px 3px 3px 0px $shadow-color; box-shadow: 3px 3px 3px 0 $shadow-color;
z-index: 1; z-index: 1;
} }
@ -436,7 +436,7 @@ header {
/*--------------------------------CONTENT------------------------------*/ /*--------------------------------CONTENT------------------------------*/
#quick_notif { #quick_notif {
width: 100%; width: 100%;
margin: 0px auto; margin: 0 auto;
list-style-type: none; list-style-type: none;
background: $second-color; background: $second-color;
li { li {
@ -447,7 +447,7 @@ header {
#content { #content {
padding: 1em 1%; padding: 1em 1%;
box-shadow: $shadow-color 0px 5px 10px; box-shadow: $shadow-color 0 5px 10px;
background: $white-color; background: $white-color;
overflow: auto; overflow: auto;
} }
@ -492,7 +492,7 @@ header {
flex-wrap: wrap; flex-wrap: wrap;
.news_column { .news_column {
display: inline-block; display: inline-block;
margin: 0px; margin: 0;
vertical-align: top; vertical-align: top;
} }
#news_admin { #news_admin {
@ -533,7 +533,7 @@ header {
margin-bottom: 1em; margin-bottom: 1em;
#agenda_title,#birthdays_title { #agenda_title,#birthdays_title {
margin: 0em; margin: 0em;
border-radius: 5px 5px 0px 0px; border-radius: 5px 5px 0 0;
box-shadow: $shadow-color 1px 1px 1px; box-shadow: $shadow-color 1px 1px 1px;
padding: 0.5em; padding: 0.5em;
font-weight: bold; font-weight: bold;
@ -602,10 +602,10 @@ header {
font-weight: bold; font-weight: bold;
font-family: monospace; font-family: monospace;
font-size: 1.4em; font-size: 1.4em;
border-radius: 7px 0px 0px 7px; border-radius: 7px 0 0 7px;
div { div {
margin: 0px auto; margin: 0 auto;
.day { .day {
font-size: 1.5em; font-size: 1.5em;
} }
@ -690,7 +690,7 @@ header {
padding: 0.4em; padding: 0.4em;
padding-left: 1em; padding-left: 1em;
background: $secondary-neutral-light-color; background: $secondary-neutral-light-color;
box-shadow: $shadow-color 0px 0px 2px; box-shadow: $shadow-color 0 0 2px;
border-radius: 18px 5px 18px 5px; border-radius: 18px 5px 18px 5px;
h4 { h4 {
margin: 0em; margin: 0em;
@ -1023,7 +1023,7 @@ h1, h2, h3, h4, h5, h6 {
h1 { h1 {
font-size: 160%; font-size: 160%;
margin-left: 0px; margin-left: 0;
} }
h2 { h2 {
@ -1053,7 +1053,7 @@ h6 {
p, pre { p, pre {
margin-top: 0.8em; margin-top: 0.8em;
margin-left: 0px; margin-left: 0;
} }
ul, ol, dl { ul, ol, dl {
@ -1111,7 +1111,7 @@ td {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
> ul { > ul {
margin-top: 0px; margin-top: 0;
} }
} }
@ -1464,7 +1464,7 @@ textarea {
} }
.search_bar { .search_bar {
margin: 10px 0px; margin: 10px 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
height: 20p; height: 20p;
@ -1480,7 +1480,7 @@ textarea {
margin-top: 5px; margin-top: 5px;
background: $secondary-color; background: $secondary-color;
color: white; color: white;
border-radius: 10px 10px 0px 0px; border-radius: 10px 10px 0 0;
.title { .title {
text-transform: uppercase; text-transform: uppercase;
} }
@ -1520,7 +1520,7 @@ textarea {
text-align: center; text-align: center;
img { img {
max-width: 70%; max-width: 70%;
margin: 0px auto; margin: 0 auto;
} }
} }
@ -1608,7 +1608,7 @@ footer {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
background-color: $primary-neutral-dark-color; background-color: $primary-neutral-dark-color;
box-shadow: $shadow-color 0px 0px 15px; box-shadow: $shadow-color 0 0 15px;
a { a {
padding: 0.8em; padding: 0.8em;
flex: 1; flex: 1;
@ -1624,7 +1624,7 @@ footer {
/*---------------------------------FORMS-------------------------------*/ /*---------------------------------FORMS-------------------------------*/
form { form {
margin: 0px auto; margin: 0 auto;
margin-bottom: 10px; margin-bottom: 10px;
} }
@ -1638,7 +1638,7 @@ label {
} }
.ui-dialog .ui-dialog-buttonpane { .ui-dialog .ui-dialog-buttonpane {
bottom: 0px; bottom: 0;
position: absolute; position: absolute;
width: 97%; width: 97%;
} }
@ -1683,8 +1683,8 @@ label {
/*-------------------------------MARKDOWN------------------------------*/ /*-------------------------------MARKDOWN------------------------------*/
.markdown { .markdown {
margin: 0px; margin: 0;
padding: 0px; padding: 0;
code { code {
font-family: monospace; font-family: monospace;
color: $white-color; color: $white-color;
@ -1715,7 +1715,7 @@ label {
} }
.ui-corner-all, .ui-corner-bottom, .ui-corner-right, .ui-corner-top, .ui-corner-all, .ui-corner-bottom, .ui-corner-right, .ui-corner-top,
.ui-corner-left { .ui-corner-left {
border-radius: 0px; border-radius: 0;
} }
#club_detail { #club_detail {
@ -1741,13 +1741,13 @@ $pedagogy-white-text: #f0f0f0;
.pedagogy { .pedagogy {
&.star-not-checked { &.star-not-checked {
color : #f7f7f7; color : #f7f7f7;
margin-bottom: 0px; margin-bottom: 0;
margin-top: 0px; margin-top: 0;
} }
&.star-checked { &.star-checked {
color: $pedagogy-orange; color: $pedagogy-orange;
margin-bottom: 0px; margin-bottom: 0;
margin-top: 0px; margin-top: 0;
} }
&.grade-without-star { &.grade-without-star {
@ -2119,8 +2119,8 @@ $pedagogy-white-text: #f0f0f0;
grid-area: markdown; grid-area: markdown;
min-height: 139px; min-height: 139px;
margin-top: 0px; margin-top: 0;
margin-right: 0px; margin-right: 0;
padding: 10px; padding: 10px;
text-align: justify; text-align: justify;
overflow: auto; overflow: auto;
@ -2166,7 +2166,7 @@ $pedagogy-white-text: #f0f0f0;
"report" "report"
"date" "date"
"author"; "author";
margin-top: 0px; margin-top: 0;
text-align: center; text-align: center;
} }
@ -2181,7 +2181,7 @@ $pedagogy-white-text: #f0f0f0;
@media screen and (max-width: $large-devices){ @media screen and (max-width: $large-devices){
clip-path: none; clip-path: none;
padding: 0px; padding: 0;
padding-bottom: 7px; padding-bottom: 7px;
} }

View File

@ -0,0 +1,191 @@
#eboutic {
display: flex;
flex-direction: row-reverse;
align-items: flex-start;
column-gap: 20px;
margin: 0 20px 20px;
}
#eboutic-title {
margin-left: 20px;
}
#eboutic h3 {
margin-left: 0;
margin-right: 0;
}
#basket {
width: 300px;
border-radius: 8px;
box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px;
padding: 10px;
}
#basket h3 {
margin-top: 0;
}
@media screen and (max-width: 765px) {
#eboutic {
flex-direction: column;
align-items: center;
margin: 10px;
row-gap: 20px;
}
#eboutic-title {
margin-bottom: 20px;
margin-top: 4px;
}
#basket {
}
}
#eboutic #basket .error-message {
margin-top: 5px;
background-color: #f8d7da;
border: #f5c6cb 1px solid;
border-radius: 4px;
padding: 10px;
display: flex;
flex-direction: column;
row-gap: 7px;
}
#eboutic #basket .error-message p {
margin: 0;
}
#eboutic .item-list {
margin-left: 0;
list-style: none;
}
#eboutic .item-list li {
display: flex;
align-items: center;
margin-bottom: 10px
}
#eboutic .item-name {
flex: 1;
}
#eboutic .item-price, #eboutic .item-quantity {
width: 65px;
}
#eboutic .item-quantity {
text-align: center;
}
#eboutic .fa-plus, #eboutic .fa-minus {
cursor: pointer;
}
#eboutic .item-price {
text-align: right;
}
/* CSS du catalogue */
#eboutic #catalog {
display: flex;
flex-grow: 1;
flex-direction: column;
row-gap: 30px;
}
#eboutic .category-header {
margin-bottom: 15px;
}
#eboutic .product-group {
display: flex;
flex-wrap: wrap;
column-gap: 15px;
row-gap: 15px;
}
@media screen and (max-width: 765px) {
#eboutic #catalog {
row-gap: 15px;
}
#eboutic section {
text-align: center;
}
#eboutic .product-group {
justify-content: space-around;
}
}
#eboutic .product-button {
position: relative;
box-sizing: border-box;
min-width: 120px;
max-width: 150px;
padding: 10px;
overflow: hidden;
box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px;
display: flex;
flex-direction: column;
align-items: center;
row-gap: 5px;
}
#eboutic .product-button:active {
box-shadow: none;
}
#eboutic .product-button img, #eboutic .product-button .fa {
margin: 0;
border-radius: 4px;
height: 40px;
line-height: 40px;
}
#eboutic .product-button p {
font-size: 13px;
margin: 0;
}
#eboutic .catalog-buttons {
display: flex;
justify-content: center;
column-gap: 30px;
margin: 30px 0 0;
}
#eboutic input {
all: unset;
}
#eboutic .catalog-buttons button {
font-size: 15px;
font-weight: normal;
color: white;
min-width: 60px;
padding: 5px 10px;
}
#eboutic .catalog-buttons .validate {
background-color: #354a5f;
}
#eboutic .catalog-buttons .clear {
background-color: gray;
}
#eboutic .catalog-buttons button i {
margin-right: 4px;
}
#eboutic .catalog-buttons button.validate:hover {
background-color: #2c3646;
}
#eboutic .catalog-buttons button.clear:hover {
background-color:hsl(210,5%,30%);
}
#eboutic .catalog-buttons form {
margin: 0;
}

View File

@ -0,0 +1,104 @@
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
function get_starting_items() {
const cookie = getCookie("basket_items")
try {
// django cookie backend does an utter mess on non-trivial data types
// so we must perform a conversion of our own
const biscuit = JSON.parse(JSON.parse(cookie.replace(/\\054/g, ',')));
if (Array.isArray(biscuit)) {
return biscuit;
}
return [];
} catch (e) {
return [];
}
}
document.addEventListener('alpine:init', () => {
Alpine.data('basket', () => ({
items: get_starting_items(),
get_total() {
let total = 0;
for (const item of this.items) {
total += item["quantity"] * item["unit_price"];
}
return total;
},
add(item) {
item.quantity++;
this.edit_cookies()
},
remove(item_id) {
const index = this.items.findIndex(e => e.id === item_id);
if (index < 0) return;
this.items[index].quantity -= 1;
if (this.items[index].quantity === 0) {
this.items = this.items.filter((e) => e.id !== this.items[index].id);
}
this.edit_cookies();
},
clear_basket() {
this.items = []
this.edit_cookies();
},
edit_cookies() {
// a cookie survives an hour
document.cookie = "basket_items=" + JSON.stringify(this.items) + ";Max-Age=3600";
},
/**
* Create an item in the basket if it was not already in
* @param id : int the id of the product to add
* @param name : String the name of the product
* @param price : number the unit price of the product
*/
create_item(id, name, price) {
let new_item = {
id: id,
name: name,
quantity: 0,
unit_price: price
};
this.items.push(new_item);
this.add(new_item);
},
/**
* add an item to the basket.
* This is called when the user click
* on a button in the catalog (left side of the page)
* @param id : int the id of the product to add
* @param name : String the name of the product
* @param price : number the unit price of the product
*/
add_from_catalog(id, name, price) {
const item = this.items.find(e => e.id === id)
if (item === undefined) {
this.create_item(id, name, price);
} else {
// the user clicked on an item which is already in the basket
this.add(item);
}
},
}))
})

View File

@ -9,7 +9,10 @@
<link rel="stylesheet" href="{{ static('core/jquery.datetimepicker.min.css') }}"> <link rel="stylesheet" href="{{ static('core/jquery.datetimepicker.min.css') }}">
<link rel="stylesheet" href="{{ static('ajax_select/css/ajax_select.css') }}"> <link rel="stylesheet" href="{{ static('ajax_select/css/ajax_select.css') }}">
<link rel="stylesheet" href="{{ scss('core/style.scss') }}"> <link rel="stylesheet" href="{{ scss('core/style.scss') }}">
{% block jquery_css %}
{# Thile file is quite heavy (around 250kb), so declaring it in a block allows easy removal #}
<link rel="stylesheet" href="{{ static('core/js/ui/jquery-ui.min.css') }}"> <link rel="stylesheet" href="{{ static('core/js/ui/jquery-ui.min.css') }}">
{% endblock %}
<link rel="preload" as="style" href="{{ static('core/font-awesome/css/font-awesome.min.css') }}" onload="this.onload=null;this.rel='stylesheet'"> <link rel="preload" as="style" href="{{ static('core/font-awesome/css/font-awesome.min.css') }}" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="{{ static('core/font-awesome/css/font-awesome.min.css') }}"></noscript> <noscript><link rel="stylesheet" href="{{ static('core/font-awesome/css/font-awesome.min.css') }}"></noscript>
<script defer href="{{ static('core/font-awesome/js/fontawesone.min.js') }}"></script> <script defer href="{{ static('core/font-awesome/js/fontawesone.min.js') }}"></script>
@ -18,6 +21,8 @@
<script src="{{ static('core/js/jquery-3.1.0.min.js') }}"></script> <script src="{{ static('core/js/jquery-3.1.0.min.js') }}"></script>
<!-- Put here to always have acces to those functions on django widgets --> <!-- Put here to always have acces to those functions on django widgets -->
<script src="{{ static('core/js/script.js') }}"></script> <script src="{{ static('core/js/script.js') }}"></script>
{% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %}
{% endblock %} {% endblock %}
</head> </head>
@ -179,7 +184,9 @@
</div> </div>
<a href="{{ url('forum:main') }}">{% trans %}Forum{% endtrans %}</a> <a href="{{ url('forum:main') }}">{% trans %}Forum{% endtrans %}</a>
<a href="{{ url('sas:main') }}">{% trans %}Gallery{% endtrans %}</a> <a href="{{ url('sas:main') }}">{% trans %}Gallery{% endtrans %}</a>
{% if request.user.is_authenticated %}
<a href="{{ url('eboutic:main') }}">{% trans %}Eboutic{% endtrans %}</a> <a href="{{ url('eboutic:main') }}">{% trans %}Eboutic{% endtrans %}</a>
{% endif %}
<div class="dropdown"> <div class="dropdown">
<button class="dropbtn">{% trans %}Services{% endtrans %} <button class="dropbtn">{% trans %}Services{% endtrans %}
<i class="fa fa-caret-down"></i> <i class="fa fa-caret-down"></i>

View File

@ -31,7 +31,6 @@ from django.urls import reverse
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
from django.forms import ValidationError from django.forms import ValidationError
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.core.exceptions import PermissionDenied
from datetime import timedelta, date from datetime import timedelta, date
import random import random
@ -73,7 +72,16 @@ class Customer(models.Model):
return self.recorded_products - number >= -settings.SITH_ECOCUP_LIMIT return self.recorded_products - number >= -settings.SITH_ECOCUP_LIMIT
@property @property
def can_buy(self): def can_buy(self) -> bool:
"""
Check if whether this customer has the right to
purchase any item.
This must be not confused with the Product.can_be_sold_to(user)
method as the present method returns an information
about a customer whereas the other tells something
about the relation between a User (not a Customer,
don't mix them) and a Product.
"""
return self.user.subscriptions.last() and ( return self.user.subscriptions.last() and (
date.today() date.today()
- self.user.subscriptions.order_by("subscription_end") - self.user.subscriptions.order_by("subscription_end")
@ -81,6 +89,7 @@ class Customer(models.Model):
.subscription_end .subscription_end
) < timedelta(days=90) ) < timedelta(days=90)
@staticmethod
def generate_account_id(number): def generate_account_id(number):
number = str(number) number = str(number)
letter = random.choice(string.ascii_lowercase) letter = random.choice(string.ascii_lowercase)
@ -203,12 +212,32 @@ class Product(models.Model):
return True return True
return False return False
def __str__(self):
return "%s (%s)" % (self.name, self.code)
def get_absolute_url(self): def get_absolute_url(self):
return reverse("counter:product_list") return reverse("counter:product_list")
def can_be_sold_to(self, user: User) -> bool:
"""
Check if whether the user given in parameter has the right to buy
this product or not.
This must be not confused with the Customer.can_buy()
method as the present method returns an information
about the relation between a User and a Product,
whereas the other tells something about a Customer
(and not a user, they are not the same model).
:return: True if the user can buy this product else False
"""
if not self.buying_groups.exists():
return True
for group in self.buying_groups.all():
if user.is_in_group(group.name):
return True
return False
def __str__(self):
return "%s (%s)" % (self.name, self.code)
class Counter(models.Model): class Counter(models.Model):
name = models.CharField(_("name"), max_length=30) name = models.CharField(_("name"), max_length=30)

94
eboutic/README.md Normal file
View File

@ -0,0 +1,94 @@
Eboutic
=======
# Aperçu général
L'application eboutic contient les vues, les templates et les modèles
nécessaires pour le fonctionnement de la boutique en ligne.
L'utilisateur peut acheter des produits en ligne et payer soit
par carte bancaire soit avec l'argent qu'il possède sur son compte AE.
On effectue ici une séparation entre plusieurs types de produits :
- les recharges de compte (``Refilling``) qui permettent de rajouter de
l'argent sur le compte. Un panier contenant un élément de ce type ne pourra être
payé que par carte bancaire.
- Les cotisations (``Subscription``) qui permettent à un utilisateur de cotiser
à l'AE. Seul un utilisateur ayant déjà été cotisant pourra acheter ce produit.
Les primo-cotisants doivent s'adresser directement à un membre du bureau.
- Les produits normaux (badges, écussons, billets pour les soirées...)
Les utilisateurs cotisants ne peuvent pas voir les produits des non-cotisants
et vice-versa. Ce n'est pas la manière la plus satisfaisante de procéder, mais
c'est celle qui touchait le moins aux tables déjà en place.
Les produits avec une limite d'âge ne seront pas visibles par les utilisateurs ayant
un âge inférieur.
Le comportement de cette application dépend directement de celui des applications
``core`` et ``counter``. Les modèles de l'application ``Subscription`` sont
également utilisés. N'hésitez donc pas à vous pencher sur le fonctionnement des modules
susnommés afin de comprendre comment celui-ci marche.
# Les vues
Cette application contient les vues suivantes :
- `eboutic_main` (GET) : la vue retournant la page principale de la boutique en ligne.
Cette vue effectue un filtrage des produits à montrer à l'utilisateur en
fonction de ce qu'il a le droit d'acheter.
Si cette vue est appelée lors d'une redirection parce qu'une erreur
est survenue au cours de la navigation sur la boutique, il est possible
de donner les messages d'erreur à donner à l'utilisateur dans la session
avec la clef ``"errors"``.
- ``payment_result`` (GET) : retourne une page assez simple disant à l'utilisateur
si son paiement a échoué ou réussi. Cette vue est appelée par redirection
lorsque l'utilisateur paye son panier avec son argent du compte AE.
- ``EbouticCommand`` (POST) : traite la soumission d'un panier par l'utilisateur.
Lors de l'appel de cette vue, la requête doit contenir un cookie avec l'état
du panier à valider. Ce panier doit strictement être de la forme :
```
[
{"id": <int>, "name": <str>, "quantity": <int>, "unit_price": <float>},
{"id": <int>, "name": <str>, "quantity": <int>, "unit_price": <float>},
<etc.>
]
```
Si le panier est mal formaté ou contient des valeurs invalides,
une redirection est faite vers `eboutic_main`.
- ``pay_with_sith`` (POST) : paie le panier avec l'argent présent sur le compte
AE. Redirige vers `payment_result`.
- ``ETransactionAutoAnswer`` (GET) : vue destinée à communiquer avec le service
de paiement bancaire pour valider ou non le paiement de l'utilisateur.
# Les templates
- ``eboutic_payment_result.jinja`` : très court template contenant juste
un message pour dire à l'utilisateur si son achat s'est bien déroulé.
Retourné par la vue ``payment_result``.
- ``eboutic_makecommand.jinja`` : template contenant un résumé du panier et deux
boutons, un pour payer avec le site AE et l'autre pour payer par carte bancaire.
Retourné par la vue ``EbouticCommand``
- ``eboutic_main.jinja`` : le plus gros template de cette application. Contient
une interface pour que l'utilisateur puisse consulter les produits et remplir
son panier. Les opérations de remplissage du panier se font entièrement côté client.
À chaque clic pour ajouter ou retirer un élément du panier, le script JS
(AlpineJS, plus précisément) édite en même temps un cookie.
Au moment de la validation du panier, ce cookie est envoyé au serveur pour
vérifier que la commande est valide et payer.
# Les modèles
- ``Basket`` : représente le panier d'un utilisateur. Un objet ``Basket`` est créé
quand l'utilisateur soumet son panier et supprimé quand le paiement est validé.
Si le paiement n'est pas validé, le panier est conservé en base de données
afin d'avoir une trace si l'utilisateur a quand même été débité et qu'il n'a pas
reçu ses produits à cause d'une erreur de synchronisation.
- ``Invoice`` : Un peu comme un panier, mais pour quand une transaction
a été validée.
- ``AbstractBaseItem`` : modèle utilisé pour être hérité par ``BasketItem`` et ``InvoiceItem``.
Me demandez pas pourquoi ``product_id`` est un IntegerField plutôt qu'une clef étrangère,
moi aussi je trouve ça complètement con.
Le prix unitaire est indiqué dans ce modèle même si ça fait une redondance avec le modèle
``Product`` afin de garder le prix que l'élément avait au moment de sa vente,
même si le prix du produit de base est modifié.
- ``BasketItem`` : représente un élément présent dans le panier de l'utilisateur.
- ``InvoiceItem`` : représente un élément vendu.

View File

@ -28,3 +28,4 @@ from eboutic.models import *
admin.site.register(Basket) admin.site.register(Basket)
admin.site.register(Invoice) admin.site.register(Invoice)
admin.site.register(BasketItem)

39
eboutic/converters.py Normal file
View File

@ -0,0 +1,39 @@
# -*- coding:utf-8 -*
#
# Copyright 2022
# - Maréchal <thgirod@hotmail.com
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
class PaymentResultConverter:
"""
Converter used for url mapping of the ``eboutic.views.payment_result``
view.
It's meant to build an url that can match
either ``/eboutic/pay/success/`` or ``/eboutic/pay/failure/``
but nothing else.
"""
regex = "(success|failure)"
def to_python(self, value):
return str(value)
def to_url(self, value):
return str(value)

175
eboutic/forms.py Normal file
View File

@ -0,0 +1,175 @@
# -*- coding:utf-8 -*
#
# Copyright 2022
# - Maréchal <thgirod@hotmail.com
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import json
import re
import typing
from django.http import HttpRequest
from django.utils.translation import gettext as _
from eboutic.models import get_eboutic_products
class BasketForm:
"""
Class intended to perform checks on the request sended to the server when
the user submits his basket from /eboutic/
Because it must check an unknown number of fields, coming from a cookie
and needing some databases checks to be performed, inheriting from forms.Form
or using formset would have been likely to end in a big ball of wibbly-wobbly hacky stuff.
Thus this class is a pure standalone and performs its operations by its own means.
However, it still tries to share some similarities with a standard django Form.
Example:
::
def my_view(request):
form = BasketForm(request)
form.clean()
if form.is_valid():
# perform operations
else:
errors = form.get_error_messages()
# return the cookie that was in the request, but with all
# incorrects elements removed
cookie = form.get_cleaned_cookie()
You can also use a little shortcut by directly calling `form.is_valid()`
without calling `form.clean()`. In this case, the latter method shall be
implicitly called.
"""
# check the json is an array containing non-nested objects.
# values must be strings or numbers
# this is matched :
# [{"id": 4, "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
# but this is not :
# [{"id": {"nested_id": 10}, "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
# and neither does this :
# [{"id": ["nested_id": 10], "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
# and neither does that :
# [{"id": null, "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
json_cookie_re = re.compile(
r"^\[\s*(\{\s*(\"[^\"]*\":\s*(\"[^\"]{0,64}\"|\d{0,5}\.?\d+),?\s*)*\},?\s*)*\s*\]$"
)
def __init__(self, request: HttpRequest):
self.user = request.user
self.cookies = request.COOKIES
self.error_messages = set()
self.correct_cookie = []
def clean(self) -> None:
"""
Perform all the checks, but return nothing.
To know if the form is valid, the `is_valid()` method must be used.
The form shall be considered as valid if it meets all the following conditions :
- it contains a "basket_items" key in the cookies of the request given in the constructor
- this cookie is a list of objects formatted this way : `[{'id': <int>, 'quantity': <int>,
'name': <str>, 'unit_price': <float>}, ...]`. The order of the fields in each object does not matter
- all the ids are positive integers
- all the ids refer to products available in the EBOUTIC
- all the ids refer to products the user is allowed to buy
- all the quantities are positive integers
"""
basket = self.cookies.get("basket_items", None)
if basket is None or basket in ("[]", ""):
self.error_messages.add(_("You have no basket."))
return
# check that the json is not nested before parsing it to make sure
# malicious user can't ddos the server with deeply nested json
if not BasketForm.json_cookie_re.match(basket):
self.error_messages.add(_("The request was badly formatted."))
return
try:
basket = json.loads(basket)
except json.JSONDecodeError:
self.error_messages.add(_("The basket cookie was badly formatted."))
return
if type(basket) is not list or len(basket) == 0:
self.error_messages.add(_("Your basket is empty."))
return
for item in basket:
expected_keys = {"id", "quantity", "name", "unit_price"}
if type(item) is not dict or set(item.keys()) != expected_keys:
self.error_messages.add("One or more items are badly formatted.")
continue
# check the id field is a positive integer
if type(item["id"]) is not int or item["id"] < 0:
self.error_messages.add(
_("%(name)s : this product does not exist.")
% {"name": item["name"]}
)
continue
# check a product with this id does exist
ids = {product.id for product in get_eboutic_products(self.user)}
if not item["id"] in ids:
self.error_messages.add(
_(
"%(name)s : this product does not exist or may no longer be available."
)
% {"name": item["name"]}
)
continue
if type(item["quantity"]) is not int or item["quantity"] < 0:
self.error_messages.add(
_("You cannot buy %(nbr)d %(name)%s.")
% {"nbr": item["quantity"], "name": item["name"]}
)
continue
# if we arrive here, it means this item has passed all tests
self.correct_cookie.append(item)
# for loop for item checking ends here
# this function does not return anything.
# instead, it fills a set containing the collected error messages
# an empty set means that no error was seen thus everything is ok
# and the form is valid.
# a non-empty set means there was at least one error thus
# the form is invalid
def is_valid(self) -> bool:
"""
return True if the form is correct else False.
If the `clean()` method has not been called beforehand, call it
"""
if self.error_messages == set() and self.correct_cookie == []:
self.clean()
if self.error_messages:
return False
return True
def get_error_messages(self) -> typing.List[str]:
# return [msg for msg in self.error_messages]
return list(self.error_messages)
def get_cleaned_cookie(self) -> str:
if not self.correct_cookie:
return ""
return json.dumps(self.correct_cookie)

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.15 on 2022-10-05 20:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("eboutic", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="basketitem",
name="quantity",
field=models.PositiveIntegerField(verbose_name="quantity"),
),
migrations.AlterField(
model_name="invoiceitem",
name="quantity",
field=models.PositiveIntegerField(verbose_name="quantity"),
),
]

View File

@ -21,15 +21,29 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import typing
from typing import List
from django.db import models, DataError
from django.utils.translation import gettext_lazy as _
from django.utils.functional import cached_property
from django.conf import settings from django.conf import settings
from django.db import models, DataError
from django.db.models import Sum, F
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from accounting.models import CurrencyField from accounting.models import CurrencyField
from core.models import Group, User
from counter.models import Counter, Product, Selling, Refilling from counter.models import Counter, Product, Selling, Refilling
from core.models import User
def get_eboutic_products(user: User) -> List[Product]:
products = (
Counter.objects.get(type="EBOUTIC")
.products.filter(product_type__isnull=False)
.filter(archived=False)
.filter(limit_age__lte=user.age)
.annotate(category=F("product_type__name"))
)
return [p for p in products if p.can_be_sold_to(user)]
class Basket(models.Model): class Basket(models.Model):
@ -46,7 +60,13 @@ class Basket(models.Model):
) )
date = models.DateTimeField(_("date"), auto_now=True) date = models.DateTimeField(_("date"), auto_now=True)
def add_product(self, p, q=1): def add_product(self, p: Product, q: int = 1):
"""
Given p an object of the Product model and q an integer,
add q items corresponding to this Product from the basket.
If this function is called with a product not in the basket, no error will be raised
"""
item = self.items.filter(product_id=p.id).first() item = self.items.filter(product_id=p.id).first()
if item is None: if item is None:
BasketItem( BasketItem(
@ -61,25 +81,53 @@ class Basket(models.Model):
item.quantity += q item.quantity += q
item.save() item.save()
def del_product(self, p, q=1): def del_product(self, p: Product, q: int = 1):
item = self.items.filter(product_id=p.id).first() """
if item is not None: Given p an object of the Product model and q an integer,
remove q items corresponding to this Product from the basket.
If this function is called with a product not in the basket, no error will be raised
"""
try:
item = self.items.get(product_id=p.id)
except BasketItem.DoesNotExist:
return
item.quantity -= q item.quantity -= q
item.save()
if item.quantity <= 0: if item.quantity <= 0:
item.delete() item.delete()
else:
item.save()
def clear(self) -> None:
"""
Remove all items from this basket without deleting the basket
"""
BasketItem.objects.filter(basket=self).delete()
@cached_property @cached_property
def contains_refilling_item(self): def contains_refilling_item(self) -> bool:
return self.items.filter( return self.items.filter(
type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
).exists() ).exists()
def get_total(self): def get_total(self) -> float:
total = 0 total = self.items.aggregate(
for i in self.items.all(): total=Sum(F("quantity") * F("product_unit_price"))
total += i.quantity * i.product_unit_price )["total"]
return total return float(total) if total is not None else 0
@classmethod
def from_session(cls, session) -> typing.Union["Basket", None]:
"""
Given an HttpRequest django object, return the basket used in the current session
if it exists else create a new one and return it
"""
if "basket_id" in session:
try:
return cls.objects.get(id=session["basket_id"])
except cls.DoesNotExist:
return None
return None
def __str__(self): def __str__(self):
return "%s's basket (%d items)" % (self.user, self.items.all().count()) return "%s's basket (%d items)" % (self.user, self.items.all().count())
@ -100,14 +148,11 @@ class Invoice(models.Model):
date = models.DateTimeField(_("date"), auto_now=True) date = models.DateTimeField(_("date"), auto_now=True)
validated = models.BooleanField(_("validated"), default=False) validated = models.BooleanField(_("validated"), default=False)
def __str__(self): def get_total(self) -> float:
return "%s - %s - %s" % (self.user, self.get_total(), self.date) total = self.items.aggregate(
total=Sum(F("quantity") * F("product_unit_price"))
def get_total(self): )["total"]
total = 0 return float(total) if total is not None else 0
for i in self.items.all():
total += i.quantity * i.product_unit_price
return total
def validate(self, *args, **kwargs): def validate(self, *args, **kwargs):
if self.validated: if self.validated:
@ -153,13 +198,16 @@ class Invoice(models.Model):
self.validated = True self.validated = True
self.save() self.save()
def __str__(self):
return "%s - %s - %s" % (self.user, self.get_total(), self.date)
class AbstractBaseItem(models.Model): class AbstractBaseItem(models.Model):
product_id = models.IntegerField(_("product id")) product_id = models.IntegerField(_("product id"))
product_name = models.CharField(_("product name"), max_length=255) product_name = models.CharField(_("product name"), max_length=255)
type_id = models.IntegerField(_("product type id")) type_id = models.IntegerField(_("product type id"))
product_unit_price = CurrencyField(_("unit price")) product_unit_price = CurrencyField(_("unit price"))
quantity = models.IntegerField(_("quantity")) quantity = models.PositiveIntegerField(_("quantity"))
class Meta: class Meta:
abstract = True abstract = True

View File

@ -1,80 +1,110 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block title %} {% block title %}
{% trans %}Eboutic{% endtrans %} {% trans %}Eboutic{% endtrans %}
{% endblock %} {% endblock %}
{% macro add_product(id, content, class="") %} {% block jquery_css %}
<form method="post" action="{{ url('eboutic:main') }}" class="inline {{ class }}"> {# Remove jquery css #}
{% csrf_token %} {% endblock %}
<input type="hidden" name="action" value="add_product">
<button type="submit" name="product_id" value="{{ id }}"> {{ content|safe }} </button>
</form>
{% endmacro %}
{% macro del_product(id, content) %} {% block additional_js %}
<form method="post" action="{{ url('eboutic:main') }}" class="inline" style="display:inline"> {# This script contains the code to perform requests to manipulate the
{% csrf_token %} user basket without having to reload the page #}
<input type="hidden" name="action" value="del_product"> <script src="{{ static('eboutic/js/eboutic.js') }}"></script>
<button type="submit" name="product_id" value="{{ id }}"> {{ content }} </button> <script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
</form> {% endblock %}
{% endmacro %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('eboutic/css/eboutic.css') }}">
{% endblock %}
{% block content %} {% block content %}
<h3>{% trans %}Eboutic{% endtrans %}</h3> <h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
<div id="eboutic" x-data="basket">
<div id="basket"> <div id="basket">
<p>{% trans %}Basket: {% endtrans %}</p> <h3>Panier</h3>
<ul> {% if errors %}
{% for i in basket.items.all()|sort(attribute='id') %} <div class="error-message">
<li>{{ del_product(i.product_id, '-') }} {{ i.quantity }} {% for error in errors %}
{{ add_product(i.product_id, '+') }} {{ i.product_name }}: {{ "%0.2f"|format(i.product_unit_price*i.quantity) }} €</li> <p>{{ error }}</p>
{% endfor %} {% endfor %}
{% trans %}Your basket has been cleaned accordingly to those errors.{% endtrans %}
</div>
{% endif %}
<ul class="item-list">
{# Starting money #}
<li>
<span class="item-name">
<strong>{% trans %}Current account amount: {% endtrans %}</strong>
</span>
<span class="item-price">
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
</span>
</li>
<template x-for="item in items" :key="item.id">
<li class="item-row" x-show="item.quantity > 0">
<span class="item-name" x-text="item.name"></span>
<div class="item-quantity">
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
<span x-text="item.quantity"></span>
<i class="fa fa-plus" @click="add(item)"></i>
</div>
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
</li>
</template>
{# Total price #}
<li style="margin-top: 20px">
<span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span>
<span x-text="get_total().toFixed(2) + ' €'" class="item-price"></span>
</li>
</ul> </ul>
<p> <div class="catalog-buttons">
<strong>{% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.get_total()) }} €</strong> <button @click="clear_basket()" class="clear">
<i class="fa fa-trash"></i>
{% if customer_amount != None %} {% trans %}Clear{% endtrans %}
<br> </button>
{% trans %}Current account amount: {% endtrans %}<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
{% if not basket.contains_refilling_item %}
<br>
{% trans %}Remaining account amount: {% endtrans %}<strong>{{ "%0.2f"|format(customer_amount - basket.get_total()) }} €</strong>
{% endif %}
{% endif %}
</p>
<form method="post" action="{{ url('eboutic:command') }}"> <form method="post" action="{{ url('eboutic:command') }}">
{% csrf_token %} {% csrf_token %}
<p> <button class="validate">
<input type="submit" value="{% trans %}Proceed to command{% endtrans %}" /> <i class="fa fa-check"></i>
</p> <input type="submit" value="{% trans %}Validate{% endtrans %}"/>
</button>
</form> </form>
</div> </div>
<div> </div>
{% for t in categories %} <div id="catalog">
{% if eboutic.products.filter(product_type=t).exists() %} {% for category, items in products|groupby('category') %}
<h5>{{ t }}</h5> {% if items|count > 0 %}
{% if t.comment %} <section>
<p>{{ t.comment }}</p> {# I would have wholeheartedly directly used the header element instead
but it has already been made messy in core/style.scss #}
<div class="category-header">
<h3>{{ category }}</h3>
{% if category.comment %}
<p>{{ category.comment }}</p>
{% endif %} {% endif %}
<br /> </div>
{% for p in eboutic.products.filter(product_type=t).all() %} <div class="product-group">
{% set file = None %} {% for p in items %}
<button class="product-button"
@click="add_from_catalog({{ p.id }}, '{{ p.name }}', {{ p.selling_price }})">
{% if p.icon %} {% if p.icon %}
{% set file = p.icon.url %} <img src="{{ p.icon.url }}" alt="image de {{ p.product_name }}" width="40px"
height="40px">
{% else %} {% else %}
{% set file = static('core/img/na.gif') %} <i class="fa fa-2x fa-picture-o"></i>
{% endif %} {% endif %}
{% set prod = '<strong>%s</strong><hr><img src="%s" /><span>%s €</span>' % (p.name, file, p.selling_price) %} <p><strong>{{ p.name }}</strong></p>
{{ add_product(p.id, prod, "form_button") }} <p>{{ p.selling_price }} €</p>
</button>
{% endfor %} {% endfor %}
</div>
</section>
{% endif %} {% endif %}
{% else %}
<p>{% trans %}There are no items available for sale{% endtrans %}</p>
{% endfor %} {% endfor %}
</div> </div>
</div>
{% endblock %} {% endblock %}

View File

@ -37,7 +37,8 @@
{% if not basket.contains_refilling_item %} {% if not basket.contains_refilling_item %}
<br> <br>
{% trans %}Remaining account amount: {% endtrans %}<strong>{{ "%0.2f"|format(customer_amount - basket.get_total()) }} €</strong> {% trans %}Remaining account amount: {% endtrans %}
<strong>{{ "%0.2f"|format(customer_amount|float - basket.get_total()) }} €</strong>
{% endif %} {% endif %}
{% endif %} {% endif %}
</p> </p>
@ -61,7 +62,6 @@
</form> </form>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -4,14 +4,16 @@
<h3>{% trans %}Eboutic{% endtrans %}</h3> <h3>{% trans %}Eboutic{% endtrans %}</h3>
<div> <div>
{% if not_enough %} {% if success %}
{% trans %}Payment failed{% endtrans %}
{% else %}
{% trans %}Payment successful{% endtrans %} {% trans %}Payment successful{% endtrans %}
{% else %}
{% trans %}Payment failed{% endtrans %}
{% endif %} {% endif %}
<p><a href="{{ url('eboutic:main') }}">{% trans %}Return to eboutic{% endtrans %}</a></p> <p><a href="{{ url('eboutic:main') }}">{% trans %}Return to eboutic{% endtrans %}</a></p>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -2,6 +2,7 @@
# #
# Copyright 2016,2017 # Copyright 2016,2017
# - Skia <skia@libskia.so> # - Skia <skia@libskia.so>
# - Maréchal <thgirod@hotmail.com
# #
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM, # Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr. # http://ae.utbm.fr.
@ -21,20 +22,21 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from datetime import datetime
import re
import base64 import base64
import json
import re
import urllib import urllib
from OpenSSL import crypto
from OpenSSL import crypto
from django.conf import settings
from django.core.management import call_command
from django.db.models import Max
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.core.management import call_command
from django.conf import settings
from core.models import User from core.models import User
from counter.models import Product, Counter, Refilling from counter.models import Product, Counter, Customer, Selling
from eboutic.models import Basket
class EbouticTest(TestCase): class EbouticTest(TestCase):
@ -49,6 +51,19 @@ class EbouticTest(TestCase):
self.cotis = Product.objects.filter(code="1SCOTIZ").first() self.cotis = Product.objects.filter(code="1SCOTIZ").first()
self.eboutic = Counter.objects.filter(name="Eboutic").first() self.eboutic = Counter.objects.filter(name="Eboutic").first()
def get_busy_basket(self, user):
"""
Create and return a basket with 3 barbar and 1 cotis in it.
Edit the client session to store the basket id in it
"""
session = self.client.session
basket = Basket.objects.create(user=user)
session["basket_id"] = basket.id
session.save()
basket.add_product(self.barbar, 3)
basket.add_product(self.cotis)
return basket
def generate_bank_valid_answer_from_page_content(self, content): def generate_bank_valid_answer_from_page_content(self, content):
content = str(content) content = str(content)
basket_id = re.search(r"PBX_CMD\" value=\"(\d*)\"", content).group(1) basket_id = re.search(r"PBX_CMD\" value=\"(\d*)\"", content).group(1)
@ -69,177 +84,99 @@ class EbouticTest(TestCase):
response = self.client.get(url) response = self.client.get(url)
return response return response
def test_buy_simple_product_with_sith_account(self): def test_buy_with_sith_account(self):
self.client.login(username="subscriber", password="plop") self.client.login(username="subscriber", password="plop")
Refilling( self.subscriber.customer.amount = 100 # give money before test
amount=10, self.subscriber.customer.save()
counter=self.eboutic, basket = self.get_busy_basket(self.subscriber)
operator=self.skia, amount = basket.get_total()
customer=self.subscriber.customer, response = self.client.post(reverse("eboutic:pay_with_sith"))
).save() self.assertRedirects(response, "/eboutic/pay/success/")
response = self.client.post( new_balance = Customer.objects.get(user=self.subscriber).amount
reverse("eboutic:main"), self.assertEqual(float(new_balance), 100 - amount)
{"action": "add_product", "product_id": self.barbar.id}, self.assertEqual(
) 'basket_items=""; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/eboutic',
self.assertTrue( self.client.cookies["basket_items"].OutputString(),
'<input type="hidden" name="action" value="add_product">\\n'
' <button type="submit" name="product_id" value="4"> + </button>\\n'
"</form>\\n Barbar: 1.70 \\xe2\\x82\\xac</li>" in str(response.content)
)
response = self.client.post(reverse("eboutic:command"))
self.assertTrue(
"<tr>\\n <td>Barbar</td>\\n <td>1</td>\\n"
" <td>1.70 \\xe2\\x82\\xac</td>\\n </tr>"
in str(response.content)
)
response = self.client.post(
reverse("eboutic:pay_with_sith"), {"action": "pay_with_sith_account"}
)
self.assertTrue(
"Le paiement a \\xc3\\xa9t\\xc3\\xa9 effectu\\xc3\\xa9\\n"
in str(response.content)
)
response = self.client.get(
reverse(
"core:user_account_detail",
kwargs={
"user_id": self.subscriber.id,
"year": datetime.now().year,
"month": datetime.now().month,
},
)
)
self.assertTrue(
'class="selected_tab">Compte (8.30 \\xe2\\x82\\xac)</a>'
in str(response.content)
)
self.assertTrue(
'<td>Eboutic</td>\\n <td><a href="/user/3/">Subscribed User</a></td>\\n'
" <td>Barbar</td>\\n <td>1</td>\\n <td>1.70 \\xe2\\x82\\xac</td>\\n"
" <td>Compte utilisateur</td>" in str(response.content)
) )
def test_buy_simple_product_with_credit_card(self): def test_buy_with_sith_account_no_money(self):
self.client.login(username="subscriber", password="plop") self.client.login(username="subscriber", password="plop")
response = self.client.post( basket = self.get_busy_basket(self.subscriber)
reverse("eboutic:main"), initial = basket.get_total() - 1
{"action": "add_product", "product_id": self.barbar.id}, self.subscriber.customer.amount = initial
) self.subscriber.customer.save()
self.assertTrue( response = self.client.post(reverse("eboutic:pay_with_sith"))
'<input type="hidden" name="action" value="add_product">\\n' self.assertRedirects(response, "/eboutic/pay/failure/")
' <button type="submit" name="product_id" value="4"> + </button>\\n' new_balance = Customer.objects.get(user=self.subscriber).amount
"</form>\\n Barbar: 1.70 \\xe2\\x82\\xac</li>" in str(response.content) self.assertEqual(float(new_balance), initial)
) self.assertEqual(
response = self.client.post(reverse("eboutic:command")) 'basket_items=""; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/eboutic',
self.assertTrue( self.client.cookies["basket_items"].OutputString(),
"<tr>\\n <td>Barbar</td>\\n <td>1</td>\\n" ) # this cookie should be removed after payment
" <td>1.70 \\xe2\\x82\\xac</td>\\n </tr>"
in str(response.content)
)
response = self.generate_bank_valid_answer_from_page_content(response.content) def test_submit_basket(self):
self.assertTrue(response.status_code == 200)
self.assertTrue(response.content.decode("utf-8") == "")
response = self.client.get(
reverse(
"core:user_account_detail",
kwargs={
"user_id": self.subscriber.id,
"year": datetime.now().year,
"month": datetime.now().month,
},
)
)
self.assertTrue(
'class="selected_tab">Compte (0.00 \\xe2\\x82\\xac)</a>'
in str(response.content)
)
self.assertTrue(
'<td>Eboutic</td>\\n <td><a href="/user/3/">Subscribed User</a></td>\\n'
" <td>Barbar</td>\\n <td>1</td>\\n <td>1.70 \\xe2\\x82\\xac</td>\\n"
" <td>Carte bancaire</td>" in str(response.content)
)
def test_alter_basket_with_credit_card(self):
self.client.login(username="subscriber", password="plop") self.client.login(username="subscriber", password="plop")
response = self.client.post( self.client.cookies[
reverse("eboutic:main"), "basket_items"
{"action": "add_product", "product_id": self.barbar.id}, ] = """[
) {"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28},
self.assertTrue( {"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
'<input type="hidden" name="action" value="add_product">\\n' ]"""
' <button type="submit" name="product_id" value="4"> + </button>\\n'
"</form>\\n Barbar: 1.70 \\xe2\\x82\\xac</li>" in str(response.content)
)
response = self.client.post(reverse("eboutic:command")) response = self.client.post(reverse("eboutic:command"))
self.assertTrue( self.assertEqual(response.status_code, 200)
"<tr>\\n <td>Barbar</td>\\n <td>1</td>\\n" self.assertInHTML(
" <td>1.70 \\xe2\\x82\\xac</td>\\n </tr>" "<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
in str(response.content) response.content.decode(),
) )
self.assertInHTML(
"<tr><td>Barbar</td><td>3</td><td>1.70 €</td></tr>",
response.content.decode(),
)
self.assertIn("basket_id", self.client.session)
basket = Basket.objects.get(id=self.client.session["basket_id"])
self.assertEqual(basket.items.count(), 2)
barbar = basket.items.filter(product_name="Barbar").first()
self.assertIsNotNone(barbar)
self.assertEqual(barbar.quantity, 3)
cotis = basket.items.filter(product_name="Cotis 2 semestres").first()
self.assertIsNotNone(cotis)
self.assertEqual(cotis.quantity, 1)
self.assertEqual(basket.get_total(), 3 * 1.7 + 28)
response_altered = self.client.post( def test_submit_empty_basket(self):
reverse("eboutic:main"), self.client.login(username="subscriber", password="plop")
{"action": "add_product", "product_id": self.barbar.id}, self.client.cookies["basket_items"] = "[]"
) response = self.client.post(reverse("eboutic:command"))
self.assertTrue( self.assertRedirects(response, "/eboutic/")
'<input type="hidden" name="action" value="add_product">\\n'
' <button type="submit" name="product_id" value="4"> + </button>\\n'
"</form>\\n Barbar: 3.40 \\xe2\\x82\\xac</li>"
in str(response_altered.content)
)
response = self.generate_bank_valid_answer_from_page_content(response.content) def test_submit_invalid_basket(self):
self.assertEqual(response.status_code, 500) self.client.login(username="subscriber", password="plop")
max_id = Product.objects.aggregate(res=Max("id"))["res"]
self.client.cookies[
"basket_items"
] = f"""[
{{"id": {max_id + 1}, "name": "", "quantity": 1, "unit_price": 28}}
]"""
response = self.client.post(reverse("eboutic:command"))
self.assertIn( self.assertIn(
"Basket processing failed with error: SuspiciousOperation('Basket total and amount do not match'", 'basket_items=""',
response.content.decode("utf-8"), self.client.cookies["basket_items"].OutputString(),
) )
self.assertIn(
"Path=/eboutic",
self.client.cookies["basket_items"].OutputString(),
)
self.assertRedirects(response, "/eboutic/")
def test_buy_refill_product_with_credit_card(self): def test_submit_basket_illegal_quantity(self):
self.client.login(username="subscriber", password="plop") self.client.login(username="subscriber", password="plop")
response = self.client.post( self.client.cookies[
reverse("eboutic:main"), "basket_items"
{"action": "add_product", "product_id": self.refill.id}, ] = """[
) {"id": 4, "name": "Barbar", "quantity": -1, "unit_price": 1.7}
self.assertTrue( ]"""
'<input type="hidden" name="action" value="add_product">\\n'
' <button type="submit" name="product_id" value="3"> + </button>\\n'
"</form>\\n Rechargement 15 \\xe2\\x82\\xac: 15.00 \\xe2\\x82\\xac</li>"
in str(response.content)
)
response = self.client.post(reverse("eboutic:command")) response = self.client.post(reverse("eboutic:command"))
self.assertTrue( self.assertRedirects(response, "/eboutic/")
"<tr>\\n <td>Rechargement 15 \\xe2\\x82\\xac</td>\\n <td>1</td>\\n"
" <td>15.00 \\xe2\\x82\\xac</td>\\n </tr>"
in str(response.content)
)
response = self.generate_bank_valid_answer_from_page_content(response.content)
self.assertTrue(response.status_code == 200)
self.assertTrue(response.content.decode("utf-8") == "")
response = self.client.get(
reverse(
"core:user_account_detail",
kwargs={
"user_id": self.subscriber.id,
"year": datetime.now().year,
"month": datetime.now().month,
},
)
)
self.assertTrue(
'class="selected_tab">Compte (15.00 \\xe2\\x82\\xac)</a>'
in str(response.content)
)
self.assertTrue(
"<td>\\n <ul>\\n \\n "
"<li>1 x Rechargement 15 \\xe2\\x82\\xac - 15.00 \\xe2\\x82\\xac</li>\\n"
" \\n </ul>\\n </td>\\n"
" <td>15.00 \\xe2\\x82\\xac</td>" in str(response.content)
)
def test_buy_subscribe_product_with_credit_card(self): def test_buy_subscribe_product_with_credit_card(self):
self.client.login(username="old_subscriber", password="plop") self.client.login(username="old_subscriber", password="plop")
@ -247,48 +184,81 @@ class EbouticTest(TestCase):
reverse("core:user_profile", kwargs={"user_id": self.old_subscriber.id}) reverse("core:user_profile", kwargs={"user_id": self.old_subscriber.id})
) )
self.assertTrue("Non cotisant" in str(response.content)) self.assertTrue("Non cotisant" in str(response.content))
response = self.client.post( self.client.cookies[
reverse("eboutic:main"), "basket_items"
{"action": "add_product", "product_id": self.cotis.id}, ] = """[
) {"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28}
self.assertTrue( ]"""
'<input type="hidden" name="action" value="add_product">\\n'
' <button type="submit" name="product_id" value="1"> + </button>\\n'
"</form>\\n Cotis 1 semestre: 15.00 \\xe2\\x82\\xac</li>"
in str(response.content)
)
response = self.client.post(reverse("eboutic:command")) response = self.client.post(reverse("eboutic:command"))
self.assertTrue( self.assertInHTML(
"<tr>\\n <td>Cotis 1 semestre</td>\\n <td>1</td>\\n" "<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
" <td>15.00 \\xe2\\x82\\xac</td>\\n </tr>" response.content.decode(),
in str(response.content)
) )
basket = Basket.objects.get(id=self.client.session["basket_id"])
self.assertEqual(basket.items.count(), 1)
response = self.generate_bank_valid_answer_from_page_content(response.content)
self.assertTrue(response.status_code == 200)
self.assertTrue(response.content.decode("utf-8") == "Payment successful")
subscriber = User.objects.get(id=self.old_subscriber.id)
self.assertEqual(subscriber.subscriptions.count(), 2)
sub = subscriber.subscriptions.order_by("-subscription_end").first()
self.assertTrue(sub.is_valid_now())
self.assertEqual(sub.member, subscriber)
self.assertEqual(sub.subscription_type, "deux-semestres")
self.assertEqual(sub.location, "EBOUTIC")
def test_buy_refill_product_with_credit_card(self):
self.client.login(username="subscriber", password="plop")
# basket contains 1 refill item worth 15€
self.client.cookies["basket_items"] = json.dumps(
[{"id": 3, "name": "Rechargement 15 €", "quantity": 1, "unit_price": 15}]
)
initial_balance = self.subscriber.customer.amount
response = self.client.post(reverse("eboutic:command"))
response = self.generate_bank_valid_answer_from_page_content(response.content) response = self.generate_bank_valid_answer_from_page_content(response.content)
self.assertTrue(response.status_code == 200) self.assertTrue(response.status_code == 200)
self.assertTrue(response.content.decode("utf-8") == "") self.assertTrue(response.content.decode() == "Payment successful")
new_balance = Customer.objects.get(user=self.subscriber).amount
self.assertEqual(new_balance, initial_balance + 15)
response = self.client.get( def test_alter_basket_after_submission(self):
reverse( self.client.login(username="subscriber", password="plop")
"core:user_account_detail", self.client.cookies["basket_items"] = json.dumps(
kwargs={ [{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
"user_id": self.old_subscriber.id,
"year": datetime.now().year,
"month": datetime.now().month,
},
) )
response = self.client.post(reverse("eboutic:command"))
self.client.cookies["basket_items"] = json.dumps(
[ # alter basket
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
]
) )
self.assertTrue( self.client.post(reverse("eboutic:command"))
'class="selected_tab">Compte (0.00 \\xe2\\x82\\xac)</a>' response = self.generate_bank_valid_answer_from_page_content(response.content)
in str(response.content) self.assertEqual(response.status_code, 500)
self.assertIn(
"Basket processing failed with error: SuspiciousOperation('Basket total and amount do not match'",
response.content.decode("utf-8"),
) )
self.assertTrue(
"<td>\\n <ul>\\n \\n " def test_buy_simple_product_with_credit_card(self):
"<li>1 x Cotis 1 semestre - 15.00 \\xe2\\x82\\xac</li>\\n" self.client.login(username="subscriber", password="plop")
" \\n </ul>\\n </td>\\n" self.client.cookies["basket_items"] = json.dumps(
" <td>15.00 \\xe2\\x82\\xac</td>" in str(response.content) [{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
) )
response = self.client.get( response = self.client.post(reverse("eboutic:command"))
reverse("core:user_profile", kwargs={"user_id": self.old_subscriber.id}) response = self.generate_bank_valid_answer_from_page_content(response.content)
self.assertTrue(response.status_code == 200)
self.assertTrue(response.content.decode("utf-8") == "Payment successful")
selling = (
Selling.objects.filter(customer=self.subscriber.customer)
.order_by("-date")
.first()
) )
self.assertTrue("Cotisant jusqu\\'au" in str(response.content)) self.assertEqual(selling.payment_method, "CARD")
self.assertEqual(selling.quantity, 1)
self.assertEqual(selling.unit_price, self.barbar.selling_price)
self.assertEqual(selling.counter.type, "EBOUTIC")
self.assertEqual(selling.product, self.barbar)

View File

@ -1,7 +1,8 @@
# -*- coding:utf-8 -* # -*- coding:utf-8 -*
# #
# Copyright 2016,2017 # Copyright 2016,2017, 2022
# - Skia <skia@libskia.so> # - Skia <skia@libskia.so>
# - Maréchal <thgirod@hotmail.com>
# #
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM, # Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr. # http://ae.utbm.fr.
@ -22,17 +23,21 @@
# #
# #
from django.urls import re_path from django.urls import path, register_converter
from eboutic.views import * from eboutic.views import *
from eboutic.converters import PaymentResultConverter
register_converter(PaymentResultConverter, "res")
urlpatterns = [ urlpatterns = [
# Subscription views # Subscription views
re_path(r"^$", EbouticMain.as_view(), name="main"), path("", eboutic_main, name="main"),
re_path(r"^command$", EbouticCommand.as_view(), name="command"), path("command/", EbouticCommand.as_view(), name="command"),
re_path(r"^pay$", EbouticPayWithSith.as_view(), name="pay_with_sith"), path("pay/", pay_with_sith, name="pay_with_sith"),
re_path( path("pay/<res:result>/", payment_result, name="payment_result"),
r"^et_autoanswer$", path(
"et_autoanswer/",
EtransactionAutoAnswer.as_view(), EtransactionAutoAnswer.as_view(),
name="etransation_autoanswer", name="etransation_autoanswer",
), ),

View File

@ -22,134 +22,99 @@
# #
# #
import base64
import hmac
import json
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime from datetime import datetime
import hmac import sentry_sdk
import base64
from OpenSSL import crypto from OpenSSL import crypto
from django.urls import reverse_lazy
from django.views.generic import TemplateView, View
from django.http import HttpResponse, HttpResponseRedirect
from django.core.exceptions import SuspiciousOperation
from django.db import transaction, DataError
from django.utils.translation import gettext as _
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import SuspiciousOperation
from django.db import transaction, DatabaseError
from django.http import HttpResponse, HttpRequest
from django.shortcuts import render, redirect
from django.utils.decorators import method_decorator
from django.views.decorators.http import require_GET, require_POST
from django.views.generic import TemplateView, View
from counter.models import Customer, Counter, ProductType, Selling from counter.models import Customer, Counter, Selling
from eboutic.models import Basket, Invoice, InvoiceItem from eboutic.forms import BasketForm
from eboutic.models import Basket, Invoice, InvoiceItem, get_eboutic_products
class EbouticMain(TemplateView): @login_required
template_name = "eboutic/eboutic_main.jinja" @require_GET
def eboutic_main(request: HttpRequest) -> HttpResponse:
"""
Main view of the eboutic application.
Return an Http response whose content is of type text/html.
The latter represents the page from which a user can see
the catalogue of products that he can buy and fill
his shopping cart.
def make_basket(self, request): The purchasable products are those of the eboutic which
if "basket_id" not in request.session.keys(): # Init the basket session entry belong to a category of products of a product category
self.basket = Basket(user=request.user) (orphan products are inaccessible).
self.basket.save()
else:
self.basket = Basket.objects.filter(id=request.session["basket_id"]).first()
if self.basket is None:
self.basket = Basket(user=request.user)
self.basket.save()
request.session["basket_id"] = self.basket.id
request.session.modified = True
def get(self, request, *args, **kwargs): If the session contains a key-value pair that associates "errors"
if not request.user.is_authenticated: with a list of strings, this pair is removed from the session
return HttpResponseRedirect( and its value displayed to the user when the page is rendered.
reverse_lazy("core:login", args=self.args, kwargs=kwargs) """
+ "?next=" errors = request.session.pop("errors", None)
+ request.path products = get_eboutic_products(request.user)
) context = {
self.object = Counter.objects.filter(type="EBOUTIC").first() "errors": errors,
self.make_basket(request) "products": products,
return super(EbouticMain, self).get(request, *args, **kwargs) "customer_amount": request.user.account_balance,
}
return render(request, "eboutic/eboutic_main.jinja", context)
def post(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return HttpResponseRedirect(
reverse_lazy("core:login", args=self.args, kwargs=kwargs)
+ "?next="
+ request.path
)
self.object = Counter.objects.filter(type="EBOUTIC").first()
self.make_basket(request)
if "add_product" in request.POST["action"]:
self.add_product(request)
elif "del_product" in request.POST["action"]:
self.del_product(request)
return self.render_to_response(self.get_context_data(**kwargs))
def add_product(self, request): @require_GET
"""Add a product to the basket""" @login_required
try: def payment_result(request, result: str) -> HttpResponse:
p = self.object.products.filter(id=int(request.POST["product_id"])).first() context = {"success": result == "success"}
if not p.buying_groups.exists(): return render(request, "eboutic/eboutic_payment_result.jinja", context)
self.basket.add_product(p)
for g in p.buying_groups.all():
if request.user.is_in_group(g.name):
self.basket.add_product(p)
break
except:
pass
def del_product(self, request):
"""Delete a product from the basket"""
try:
p = self.object.products.filter(id=int(request.POST["product_id"])).first()
self.basket.del_product(p)
except:
pass
def get_context_data(self, **kwargs):
kwargs = super(EbouticMain, self).get_context_data(**kwargs)
kwargs["basket"] = self.basket
kwargs["eboutic"] = Counter.objects.filter(type="EBOUTIC").first()
kwargs["categories"] = ProductType.objects.all()
if hasattr(self.request.user, "customer"):
kwargs["customer_amount"] = self.request.user.customer.amount
else:
kwargs["customer_amount"] = None
if not self.request.user.was_subscribed:
kwargs["categories"] = kwargs["categories"].exclude(
id=settings.SITH_PRODUCTTYPE_SUBSCRIPTION
)
return kwargs
class EbouticCommand(TemplateView): class EbouticCommand(TemplateView):
template_name = "eboutic/eboutic_makecommand.jinja" template_name = "eboutic/eboutic_makecommand.jinja"
@method_decorator(login_required)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if not request.user.is_authenticated: return redirect("eboutic:main")
return HttpResponseRedirect(
reverse_lazy("core:login", args=self.args, kwargs=kwargs)
+ "?next="
+ request.path
)
return HttpResponseRedirect(
reverse_lazy("eboutic:main", args=self.args, kwargs=kwargs)
)
def post(self, request, *args, **kwargs): @method_decorator(login_required)
if not request.user.is_authenticated: def post(self, request: HttpRequest, *args, **kwargs):
return HttpResponseRedirect( form = BasketForm(request)
reverse_lazy("core:login", args=self.args, kwargs=kwargs) if not form.is_valid():
+ "?next=" request.session["errors"] = form.get_error_messages()
+ request.path request.session.modified = True
) res = redirect("eboutic:main")
if "basket_id" not in request.session.keys(): res.set_cookie("basket_items", form.get_cleaned_cookie(), path="/eboutic")
return HttpResponseRedirect( return res
reverse_lazy("eboutic:main", args=self.args, kwargs=kwargs)
) if "basket_id" in request.session:
self.basket = Basket.objects.filter(id=request.session["basket_id"]).first() basket, _ = Basket.objects.get_or_create(
if self.basket is None: id=request.session["basket_id"], user=request.user
return HttpResponseRedirect(
reverse_lazy("eboutic:main", args=self.args, kwargs=kwargs)
) )
basket.clear()
else: else:
kwargs["basket"] = self.basket basket = Basket.objects.create(user=request.user)
basket.save()
eboutique = Counter.objects.get(type="EBOUTIC")
for item in json.loads(request.COOKIES["basket_items"]):
basket.add_product(
eboutique.products.get(id=(item["id"])), item["quantity"]
)
request.session["basket_id"] = basket.id
request.session.modified = True
kwargs["basket"] = basket
return self.render_to_response(self.get_context_data(**kwargs)) return self.render_to_response(self.get_context_data(**kwargs))
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -162,12 +127,12 @@ class EbouticCommand(TemplateView):
kwargs["et_request"]["PBX_SITE"] = settings.SITH_EBOUTIC_PBX_SITE kwargs["et_request"]["PBX_SITE"] = settings.SITH_EBOUTIC_PBX_SITE
kwargs["et_request"]["PBX_RANG"] = settings.SITH_EBOUTIC_PBX_RANG kwargs["et_request"]["PBX_RANG"] = settings.SITH_EBOUTIC_PBX_RANG
kwargs["et_request"]["PBX_IDENTIFIANT"] = settings.SITH_EBOUTIC_PBX_IDENTIFIANT kwargs["et_request"]["PBX_IDENTIFIANT"] = settings.SITH_EBOUTIC_PBX_IDENTIFIANT
kwargs["et_request"]["PBX_TOTAL"] = int(self.basket.get_total() * 100) kwargs["et_request"]["PBX_TOTAL"] = int(kwargs["basket"].get_total() * 100)
kwargs["et_request"][ kwargs["et_request"][
"PBX_DEVISE" "PBX_DEVISE"
] = 978 # This is Euro. ET support only this value anyway ] = 978 # This is Euro. ET support only this value anyway
kwargs["et_request"]["PBX_CMD"] = self.basket.id kwargs["et_request"]["PBX_CMD"] = kwargs["basket"].id
kwargs["et_request"]["PBX_PORTEUR"] = self.basket.user.email kwargs["et_request"]["PBX_PORTEUR"] = kwargs["basket"].user.email
kwargs["et_request"]["PBX_RETOUR"] = "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K" kwargs["et_request"]["PBX_RETOUR"] = "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"
kwargs["et_request"]["PBX_HASH"] = "SHA512" kwargs["et_request"]["PBX_HASH"] = "SHA512"
kwargs["et_request"]["PBX_TYPEPAIEMENT"] = "CARTE" kwargs["et_request"]["PBX_TYPEPAIEMENT"] = "CARTE"
@ -192,41 +157,24 @@ class EbouticCommand(TemplateView):
return kwargs return kwargs
class EbouticPayWithSith(TemplateView): @login_required
template_name = "eboutic/eboutic_payment_result.jinja" @require_POST
def pay_with_sith(request):
def post(self, request, *args, **kwargs): basket = Basket.from_session(request.session)
try: refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
with transaction.atomic(): if basket is None or basket.items.filter(type_id=refilling).exists():
if ( return redirect("eboutic:main")
"basket_id" not in request.session.keys() c = Customer.objects.filter(user__id=basket.user.id).first()
or not request.user.is_authenticated
):
return HttpResponseRedirect(
reverse_lazy("eboutic:main", args=self.args, kwargs=kwargs)
)
b = Basket.objects.filter(id=request.session["basket_id"]).first()
if (
b is None
or b.items.filter(
type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
).exists()
):
return HttpResponseRedirect(
reverse_lazy("eboutic:main", args=self.args, kwargs=kwargs)
)
c = Customer.objects.filter(user__id=b.user.id).first()
if c is None: if c is None:
return HttpResponseRedirect( return redirect("eboutic:main")
reverse_lazy("eboutic:main", args=self.args, kwargs=kwargs) if c.amount < basket.get_total():
) res = redirect("eboutic:payment_result", "failure")
kwargs["not_enough"] = True
if c.amount < b.get_total():
raise DataError(_("You do not have enough money to buy the basket"))
else: else:
eboutic = Counter.objects.filter(type="EBOUTIC").first() eboutic = Counter.objects.filter(type="EBOUTIC").first()
for it in b.items.all(): try:
product = eboutic.products.filter(id=it.product_id).first() with transaction.atomic():
for it in basket.items.all():
product = eboutic.products.get(id=it.product_id)
Selling( Selling(
label=it.product_name, label=it.product_name,
counter=eboutic, counter=eboutic,
@ -238,16 +186,24 @@ class EbouticPayWithSith(TemplateView):
quantity=it.quantity, quantity=it.quantity,
payment_method="SITH_ACCOUNT", payment_method="SITH_ACCOUNT",
).save() ).save()
b.delete() basket.delete()
kwargs["not_enough"] = False
request.session.pop("basket_id", None) request.session.pop("basket_id", None)
except DataError as e: res = redirect("eboutic:payment_result", "success")
kwargs["not_enough"] = True except DatabaseError as e:
return self.render_to_response(self.get_context_data(**kwargs)) with sentry_sdk.push_scope() as scope:
scope.user = {"username": request.user.username}
scope.set_extra("someVariable", e.__repr__())
sentry_sdk.capture_message(
f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
)
res = redirect("eboutic:payment_result", "failure")
res.delete_cookie("basket_items", "/eboutic")
return res
class EtransactionAutoAnswer(View): class EtransactionAutoAnswer(View):
# Response documentation http://www1.paybox.com/espace-integrateur-documentation/la-solution-paybox-system/gestion-de-la-reponse/ # Response documentation http://www1.paybox.com/espace-integrateur-documentation
# /la-solution-paybox-system/gestion-de-la-reponse/
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if ( if (
not "Amount" in request.GET.keys() not "Amount" in request.GET.keys()
@ -305,7 +261,7 @@ class EtransactionAutoAnswer(View):
return HttpResponse( return HttpResponse(
"Basket processing failed with error: " + repr(e), status=500 "Basket processing failed with error: " + repr(e), status=500
) )
return HttpResponse() return HttpResponse("Payment successful", status=200)
else: else:
return HttpResponse( return HttpResponse(
"Payment failed with error: " + request.GET["Error"], status=202 "Payment failed with error: " + request.GET["Error"], status=202

File diff suppressed because it is too large Load Diff

View File

@ -690,4 +690,5 @@ SITH_FRONT_DEP_VERSIONS = {
"https://github.com/getsentry/sentry-javascript/": "4.0.6", "https://github.com/getsentry/sentry-javascript/": "4.0.6",
"https://github.com/jhuckaby/webcamjs/": "1.0.0", "https://github.com/jhuckaby/webcamjs/": "1.0.0",
"https://github.com/vuejs/vue-next": "3.2.18", "https://github.com/vuejs/vue-next": "3.2.18",
"https://github.com/alpinejs/alpine": "3.10.3",
} }