mirror of
https://github.com/ae-utbm/sith.git
synced 2024-12-22 15:51:19 +00:00
Implémentation 3DSv2 + résolution bugs eboutic + amélioration pages admin (#558)
Eboutic : - Implémentation de la norme 3DSecure v2 pour les paiement par carte bancaire - Amélioration générale de l'interface utilisateur - Résolution du problème avec les caractères spéciaux dans le panier sur Safari - Réparation du cookie du panier de l'eboutic qui n'était pas fonctionnel Autre : - Mise à jour de la documentation - Mise à jour des dépendances Javascript - Suppression du code inutilisé dans `subscription/models.py` - Amélioration des pages administrateur (back-office Django) Co-authored-by: thomas girod <56346771+imperosol@users.noreply.github.com> Co-authored-by: Théo DURR <git@theodurr.fr> Co-authored-by: Julien Constant <julienconstant190@gmail.com>
This commit is contained in:
parent
310f1a2283
commit
73305c0b28
@ -21,11 +21,24 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
from ajax_select import make_ajax_form
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, Membership
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Club)
|
@admin.register(Club)
|
||||||
admin.site.register(Membership)
|
class ClubAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "unix_name", "parent", "is_active")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Membership)
|
||||||
|
class MembershipAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "club", "role", "start_date", "end_date")
|
||||||
|
search_fields = (
|
||||||
|
"user__username",
|
||||||
|
"user__first_name",
|
||||||
|
"user__last_name",
|
||||||
|
"club__name",
|
||||||
|
)
|
||||||
|
form = make_ajax_form(Membership, {"user": "users"})
|
||||||
|
26
com/admin.py
26
com/admin.py
@ -21,23 +21,37 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
from ajax_select import make_ajax_form
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from haystack.admin import SearchModelAdmin
|
from haystack.admin import SearchModelAdmin
|
||||||
|
|
||||||
from com.models import *
|
from com.models import *
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(News)
|
||||||
class NewsAdmin(SearchModelAdmin):
|
class NewsAdmin(SearchModelAdmin):
|
||||||
search_fields = ["title", "summary", "content"]
|
list_display = ("title", "type", "club", "author")
|
||||||
|
search_fields = ("title", "summary", "content")
|
||||||
|
form = make_ajax_form(
|
||||||
|
News,
|
||||||
|
{
|
||||||
|
"author": "users",
|
||||||
|
"moderator": "users",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Poster)
|
||||||
|
class PosterAdmin(SearchModelAdmin):
|
||||||
|
list_display = ("name", "club", "date_begin", "date_end", "moderator")
|
||||||
|
form = make_ajax_form(Poster, {"moderator": "users"})
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Weekmail)
|
||||||
class WeekmailAdmin(SearchModelAdmin):
|
class WeekmailAdmin(SearchModelAdmin):
|
||||||
search_fields = ["title"]
|
list_display = ("title", "sent")
|
||||||
|
search_fields = ("title",)
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Sith)
|
admin.site.register(Sith)
|
||||||
admin.site.register(News, NewsAdmin)
|
|
||||||
admin.site.register(Weekmail, WeekmailAdmin)
|
|
||||||
admin.site.register(Screen)
|
admin.site.register(Screen)
|
||||||
admin.site.register(Poster)
|
|
||||||
|
@ -24,7 +24,7 @@
|
|||||||
<div id="progress_bar"></div>
|
<div id="progress_bar"></div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<script src="{{ static('core/js/jquery-3.1.0.min.js') }}"></script>
|
<script src="{{ static('core/js/jquery-3.6.2.min.js') }}"></script>
|
||||||
<script src="{{ static('com/js/slideshow.js') }}"></script>
|
<script src="{{ static('com/js/slideshow.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -24,17 +24,19 @@
|
|||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from ajax_select import make_ajax_form
|
from ajax_select import make_ajax_form
|
||||||
from core.models import User, Page, RealGroup, SithFile
|
from core.models import User, Page, RealGroup, MetaGroup, SithFile
|
||||||
from django.contrib.auth.models import Group as AuthGroup
|
from django.contrib.auth.models import Group as AuthGroup
|
||||||
from haystack.admin import SearchModelAdmin
|
from haystack.admin import SearchModelAdmin
|
||||||
|
|
||||||
|
|
||||||
admin.site.unregister(AuthGroup)
|
admin.site.unregister(AuthGroup)
|
||||||
|
admin.site.register(MetaGroup)
|
||||||
admin.site.register(RealGroup)
|
admin.site.register(RealGroup)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(User)
|
||||||
class UserAdmin(SearchModelAdmin):
|
class UserAdmin(SearchModelAdmin):
|
||||||
list_display = ["first_name", "last_name", "username", "email", "nick_name"]
|
list_display = ("first_name", "last_name", "username", "email", "nick_name")
|
||||||
form = make_ajax_form(
|
form = make_ajax_form(
|
||||||
User,
|
User,
|
||||||
{
|
{
|
||||||
@ -48,11 +50,9 @@ class UserAdmin(SearchModelAdmin):
|
|||||||
search_fields = ["first_name", "last_name", "username"]
|
search_fields = ["first_name", "last_name", "username"]
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(User, UserAdmin)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Page)
|
@admin.register(Page)
|
||||||
class PageAdmin(admin.ModelAdmin):
|
class PageAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "_full_name", "owner_group")
|
||||||
form = make_ajax_form(
|
form = make_ajax_form(
|
||||||
Page,
|
Page,
|
||||||
{
|
{
|
||||||
@ -66,4 +66,12 @@ class PageAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(SithFile)
|
@admin.register(SithFile)
|
||||||
class SithFileAdmin(admin.ModelAdmin):
|
class SithFileAdmin(admin.ModelAdmin):
|
||||||
form = make_ajax_form(SithFile, {"parent": "files"}) # ManyToManyField
|
list_display = ("name", "owner", "size", "date", "is_in_sas")
|
||||||
|
form = make_ajax_form(
|
||||||
|
SithFile,
|
||||||
|
{
|
||||||
|
"parent": "files",
|
||||||
|
"owner": "users",
|
||||||
|
"moderator": "users",
|
||||||
|
},
|
||||||
|
) # ManyToManyField
|
||||||
|
@ -28,8 +28,9 @@ from ajax_select import register, LookupChannel
|
|||||||
from core.views.site import search_user
|
from core.views.site import search_user
|
||||||
from core.models import User, Group, SithFile
|
from core.models import User, Group, SithFile
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
from counter.models import Product, Counter
|
from counter.models import Product, Counter, Customer
|
||||||
from accounting.models import ClubAccount, Company
|
from accounting.models import ClubAccount, Company
|
||||||
|
from eboutic.models import BasketItem
|
||||||
|
|
||||||
|
|
||||||
def check_token(request):
|
def check_token(request):
|
||||||
@ -60,6 +61,21 @@ class UsersLookup(RightManagedLookupChannel):
|
|||||||
return item.get_display_name()
|
return item.get_display_name()
|
||||||
|
|
||||||
|
|
||||||
|
@register("customers")
|
||||||
|
class CustomerLookup(RightManagedLookupChannel):
|
||||||
|
model = Customer
|
||||||
|
|
||||||
|
def get_query(self, q, request):
|
||||||
|
users = search_user(q)
|
||||||
|
return [user.customer for user in users]
|
||||||
|
|
||||||
|
def format_match(self, obj):
|
||||||
|
return obj.user.get_mini_item()
|
||||||
|
|
||||||
|
def format_item_display(self, obj):
|
||||||
|
return f"{obj.user.get_display_name()} ({obj.account_id})"
|
||||||
|
|
||||||
|
|
||||||
@register("groups")
|
@register("groups")
|
||||||
class GroupsLookup(RightManagedLookupChannel):
|
class GroupsLookup(RightManagedLookupChannel):
|
||||||
model = Group
|
model = Group
|
||||||
|
@ -26,6 +26,7 @@ import os
|
|||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from io import StringIO, BytesIO
|
from io import StringIO, BytesIO
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -73,7 +74,7 @@ class Command(BaseCommand):
|
|||||||
root_path = os.path.dirname(
|
root_path = os.path.dirname(
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
)
|
)
|
||||||
Group(name="Root").save()
|
root_group, _ = Group.objects.get_or_create(name="Root")
|
||||||
Group(name="Public").save()
|
Group(name="Public").save()
|
||||||
Group(name="Subscribers").save()
|
Group(name="Subscribers").save()
|
||||||
Group(name="Old subscribers").save()
|
Group(name="Old subscribers").save()
|
||||||
@ -87,6 +88,11 @@ class Command(BaseCommand):
|
|||||||
Group(name="Forum admin").save()
|
Group(name="Forum admin").save()
|
||||||
Group(name="Pedagogy admin").save()
|
Group(name="Pedagogy admin").save()
|
||||||
self.reset_index("core", "auth")
|
self.reset_index("core", "auth")
|
||||||
|
|
||||||
|
change_billing = Permission.objects.get(codename="change_billinginfo")
|
||||||
|
add_billing = Permission.objects.get(codename="add_billinginfo")
|
||||||
|
root_group.permissions.add(change_billing, add_billing)
|
||||||
|
|
||||||
root = User(
|
root = User(
|
||||||
id=0,
|
id=0,
|
||||||
username="root",
|
username="root",
|
||||||
|
6
core/static/core/easymde/easymde.min.css
vendored
6
core/static/core/easymde/easymde.min.css
vendored
File diff suppressed because one or more lines are too long
6
core/static/core/easymde/easymde.min.js
vendored
6
core/static/core/easymde/easymde.min.js
vendored
File diff suppressed because one or more lines are too long
2
core/static/core/js/alpinejs.min.js
vendored
2
core/static/core/js/alpinejs.min.js
vendored
File diff suppressed because one or more lines are too long
4
core/static/core/js/jquery-3.1.0.min.js
vendored
4
core/static/core/js/jquery-3.1.0.min.js
vendored
File diff suppressed because one or more lines are too long
2
core/static/core/js/jquery-3.6.2.min.js
vendored
Normal file
2
core/static/core/js/jquery-3.6.2.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -49,36 +49,27 @@ body {
|
|||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type=button], input[type=submit], input[type=reset],input[type=file] {
|
button, input[type=button], input[type=submit], input[type=reset],input[type=file] {
|
||||||
border: none;
|
border: none;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background-color: $background-button-color;
|
background-color: $background-button-color;
|
||||||
padding: 0.4em;
|
padding: 0.4em;
|
||||||
margin: 0.1em;
|
margin: 0.1em;
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
cursor: pointer;
|
&:hover {
|
||||||
box-shadow: $shadow-color 0px 0px 1px;
|
background: hsl(0, 0%, 83%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
input[type=button], input[type=submit], input[type=reset],input[type=file] {
|
||||||
background: hsl(0, 0%, 83%);
|
font-weight: bold;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
button{
|
|
||||||
border: none;
|
button:not(:disabled), input[type=button]:not(:disabled), input[type=submit]:not(:disabled), input[type=reset]:not(:disabled),input[type=file]:not(:disabled) {
|
||||||
text-decoration: none;
|
|
||||||
background-color: $background-button-color;
|
|
||||||
padding: 0.4em;
|
|
||||||
margin: 0.1em;
|
|
||||||
font-size: 1.18em;
|
|
||||||
border-radius: 5px;
|
|
||||||
box-shadow: $shadow-color 0px 0px 1px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
&:hover {
|
|
||||||
background: hsl(0, 0%, 83%);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input,textarea[type=text],[type=number]{
|
input,textarea[type=text],[type=number]{
|
||||||
border: none;
|
border: none;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@ -123,6 +114,38 @@ a {
|
|||||||
margin: 1px;
|
margin: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collapse {
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.collapse-header {
|
||||||
|
color: white;
|
||||||
|
background-color: #354a5f;
|
||||||
|
padding: 5px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.collapse-header-text {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-header-icon {
|
||||||
|
transition: all ease-in-out 150ms;
|
||||||
|
&.reverse {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.collapse-body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow {
|
||||||
|
box-shadow: rgba(60, 64, 67, .3) 0 1px 3px 0, rgba(60, 64, 67, .15) 0 4px 8px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.w_big {
|
.w_big {
|
||||||
width: 75%;
|
width: 75%;
|
||||||
}
|
}
|
||||||
@ -135,10 +158,12 @@ a {
|
|||||||
width: 23%;
|
width: 23%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clickable:hover {
|
.clickable:not(:disabled):hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
|
||||||
/*--------------------------------HEADER-------------------------------*/
|
/*--------------------------------HEADER-------------------------------*/
|
||||||
|
|
||||||
#header_language_chooser {
|
#header_language_chooser {
|
||||||
@ -170,21 +195,11 @@ header {
|
|||||||
background-color: $primary-neutral-dark-color;
|
background-color: $primary-neutral-dark-color;
|
||||||
border-radius: 0px 0px 10px 10px;
|
border-radius: 0px 0px 10px 10px;
|
||||||
|
|
||||||
// PINKTOBER
|
|
||||||
// background-color: $pinktober;
|
|
||||||
// border-bottom: 5px solid $pinktober-secondary;
|
|
||||||
// margin-bottom: -5px;
|
|
||||||
// border-radius: 0 0 5px 7px;
|
|
||||||
|
|
||||||
#header_logo {
|
#header_logo {
|
||||||
background-color: $white-color;
|
background-color: $white-color;
|
||||||
padding: 0.2em;
|
padding: 0.2em;
|
||||||
border-radius: 0px 0px 0px 9px;
|
border-radius: 0 0 0 9px;
|
||||||
|
|
||||||
//PINKTOBER
|
|
||||||
// border-bottom: 5px solid $shadow-color;
|
|
||||||
// border-radius: 0px 0px 0px 5px;
|
|
||||||
// margin-bottom: -5px;
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -211,14 +226,8 @@ header {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
label {
|
label {
|
||||||
display: inline;
|
display: inline;
|
||||||
|
|
||||||
// PINKTOBER
|
|
||||||
// color: $pinktober-primary-text;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a {
|
|
||||||
display: button;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#header_bar {
|
#header_bar {
|
||||||
@ -243,16 +252,6 @@ header {
|
|||||||
flex: initial;
|
flex: initial;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
margin: 0.2em 0.2em;
|
margin: 0.2em 0.2em;
|
||||||
|
|
||||||
/*
|
|
||||||
PINKTOBER
|
|
||||||
& .fa.fa-times {
|
|
||||||
color: $pinktober-bar-closed !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .fa.fa-check {
|
|
||||||
color: $pinktober-bar-opened !important;
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#header_search {
|
#header_search {
|
||||||
@ -445,6 +444,36 @@ header {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: normal;
|
||||||
|
color: white;
|
||||||
|
min-width: 60px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&.btn-blue {
|
||||||
|
background-color: #354a5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-blue:disabled {
|
||||||
|
background-color: rgba(70, 90, 126, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-blue.clickable:not(:disabled):hover {
|
||||||
|
background-color: #2c3646;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-grey {
|
||||||
|
background-color: grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-grey.clickable:not(:disabled):hover {
|
||||||
|
background-color:hsl(210,5%,30%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*--------------------------------CONTENT------------------------------*/
|
/*--------------------------------CONTENT------------------------------*/
|
||||||
#quick_notif {
|
#quick_notif {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -466,10 +495,7 @@ header {
|
|||||||
|
|
||||||
.alert {
|
.alert {
|
||||||
margin: 10px;
|
margin: 10px;
|
||||||
border: #fc8181 1px solid;
|
|
||||||
background-color: rgb(255,245,245);
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #c53030;
|
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@ -477,6 +503,18 @@ header {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: justify;
|
text-align: justify;
|
||||||
|
|
||||||
|
&.alert-green {
|
||||||
|
background-color: rgb(245, 255, 245);
|
||||||
|
color: rgb(3, 84, 63);
|
||||||
|
border: rgb(14, 159, 110) 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.alert-red {
|
||||||
|
background-color: rgb(255,245,245);
|
||||||
|
color: #c53030;
|
||||||
|
border: #fc8181 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
.alert-main {
|
.alert-main {
|
||||||
flex: 2;
|
flex: 2;
|
||||||
}
|
}
|
||||||
@ -1497,7 +1535,7 @@ textarea {
|
|||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
height: 20p;
|
height: 20px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.search_check {
|
.search_check {
|
||||||
|
@ -1,104 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
@ -18,7 +18,7 @@
|
|||||||
<script defer href="{{ static('core/font-awesome/js/fontawesone.min.js') }}"></script>
|
<script defer href="{{ static('core/font-awesome/js/fontawesone.min.js') }}"></script>
|
||||||
|
|
||||||
<!-- Jquery declared here to be accessible in every django widgets -->
|
<!-- Jquery declared here to be accessible in every django widgets -->
|
||||||
<script src="{{ static('core/js/jquery-3.1.0.min.js') }}"></script>
|
<script src="{{ static('core/js/jquery-3.6.2.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_css %}{% endblock %}
|
||||||
|
@ -24,7 +24,7 @@
|
|||||||
<div id="progress_bar"></div>
|
<div id="progress_bar"></div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<script src="{{ static('core/js/jquery-3.1.0.min.js') }}"></script>
|
<script src="{{ static('core/js/jquery-3.6.2.min.js') }}"></script>
|
||||||
<script src="{{ static('com/js/slideshow.js') }}"></script>
|
<script src="{{ static('com/js/slideshow.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -67,7 +67,7 @@ from core.views.forms import (
|
|||||||
)
|
)
|
||||||
from core.models import User, SithFile, Preferences, Gift
|
from core.models import User, SithFile, Preferences, Gift
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
from counter.views import StudentCardForm
|
from counter.forms import StudentCardForm
|
||||||
from trombi.views import UserTrombiForm
|
from trombi.views import UserTrombiForm
|
||||||
|
|
||||||
|
|
||||||
|
120
counter/admin.py
120
counter/admin.py
@ -21,27 +21,123 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
from ajax_select import make_ajax_form
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from haystack.admin import SearchModelAdmin
|
from haystack.admin import SearchModelAdmin
|
||||||
|
|
||||||
from counter.models import *
|
from counter.models import *
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Product)
|
||||||
class ProductAdmin(SearchModelAdmin):
|
class ProductAdmin(SearchModelAdmin):
|
||||||
search_fields = ["name", "code"]
|
list_display = (
|
||||||
|
"name",
|
||||||
|
"code",
|
||||||
|
"product_type",
|
||||||
|
"selling_price",
|
||||||
|
"profit",
|
||||||
|
"archived",
|
||||||
|
)
|
||||||
|
search_fields = ("name", "code")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Customer)
|
||||||
class CustomerAdmin(SearchModelAdmin):
|
class CustomerAdmin(SearchModelAdmin):
|
||||||
search_fields = ["account_id"]
|
list_display = ("user", "account_id", "amount")
|
||||||
|
search_fields = (
|
||||||
|
"account_id",
|
||||||
|
"user__username",
|
||||||
|
"user__first_name",
|
||||||
|
"user__last_name",
|
||||||
|
)
|
||||||
|
form = make_ajax_form(Customer, {"user": "users"})
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Customer, CustomerAdmin)
|
@admin.register(BillingInfo)
|
||||||
admin.site.register(Product, ProductAdmin)
|
class BillingInfoAdmin(admin.ModelAdmin):
|
||||||
admin.site.register(ProductType)
|
list_display = ("first_name", "last_name", "address_1", "city", "country")
|
||||||
admin.site.register(Counter)
|
|
||||||
admin.site.register(Refilling)
|
|
||||||
admin.site.register(Selling)
|
@admin.register(Counter)
|
||||||
admin.site.register(Permanency)
|
class CounterAdmin(admin.ModelAdmin):
|
||||||
admin.site.register(CashRegisterSummary)
|
list_display = ("name", "club", "type")
|
||||||
admin.site.register(Eticket)
|
form = make_ajax_form(
|
||||||
|
Counter,
|
||||||
|
{
|
||||||
|
"products": "products",
|
||||||
|
"sellers": "users",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Refilling)
|
||||||
|
class RefillingAdmin(SearchModelAdmin):
|
||||||
|
list_display = ("customer", "amount", "counter", "payment_method", "date")
|
||||||
|
search_fields = (
|
||||||
|
"customer__user__username",
|
||||||
|
"customer__user__first_name",
|
||||||
|
"customer__user__last_name",
|
||||||
|
"customer__account_id",
|
||||||
|
"counter__name",
|
||||||
|
)
|
||||||
|
form = make_ajax_form(
|
||||||
|
Refilling,
|
||||||
|
{
|
||||||
|
"customer": "customers",
|
||||||
|
"operator": "users",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Selling)
|
||||||
|
class SellingAdmin(SearchModelAdmin):
|
||||||
|
list_display = ("customer", "label", "unit_price", "quantity", "counter", "date")
|
||||||
|
search_fields = (
|
||||||
|
"customer__user__username",
|
||||||
|
"customer__user__first_name",
|
||||||
|
"customer__user__last_name",
|
||||||
|
"customer__account_id",
|
||||||
|
"counter__name",
|
||||||
|
)
|
||||||
|
form = make_ajax_form(
|
||||||
|
Selling,
|
||||||
|
{
|
||||||
|
"customer": "customers",
|
||||||
|
"seller": "users",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Permanency)
|
||||||
|
class PermanencyAdmin(SearchModelAdmin):
|
||||||
|
list_display = ("user", "counter", "start", "duration")
|
||||||
|
search_fields = (
|
||||||
|
"user__username",
|
||||||
|
"user__first_name",
|
||||||
|
"user__last_name",
|
||||||
|
"counter__name",
|
||||||
|
)
|
||||||
|
form = make_ajax_form(Permanency, {"user": "users"})
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ProductType)
|
||||||
|
class ProductTypeAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "priority")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CashRegisterSummary)
|
||||||
|
class CashRegisterSummaryAdmin(SearchModelAdmin):
|
||||||
|
list_display = ("user", "counter", "date")
|
||||||
|
search_fields = (
|
||||||
|
"user__username",
|
||||||
|
"user__first_name",
|
||||||
|
"user__last_name",
|
||||||
|
"counter__name",
|
||||||
|
)
|
||||||
|
form = make_ajax_form(CashRegisterSummary, {"user": "users"})
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Eticket)
|
||||||
|
class EticketAdmin(SearchModelAdmin):
|
||||||
|
list_display = ("product", "event_date", "event_title")
|
||||||
|
search_fields = ("product__name", "event_title")
|
||||||
|
177
counter/forms.py
Normal file
177
counter/forms.py
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
from ajax_select import make_ajax_field
|
||||||
|
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
|
||||||
|
from django import forms
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from core.views.forms import TzAwareDateTimeField, SelectDate
|
||||||
|
from counter.models import (
|
||||||
|
BillingInfo,
|
||||||
|
StudentCard,
|
||||||
|
Customer,
|
||||||
|
Refilling,
|
||||||
|
Counter,
|
||||||
|
Product,
|
||||||
|
Eticket,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingInfoForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = BillingInfo
|
||||||
|
exclude = ["customer"]
|
||||||
|
|
||||||
|
|
||||||
|
class StudentCardForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for adding student cards
|
||||||
|
Only used for user profile since CounterClick is to complicated
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = StudentCard
|
||||||
|
fields = ["uid"]
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super(StudentCardForm, self).clean()
|
||||||
|
uid = cleaned_data.get("uid", None)
|
||||||
|
if not uid or not StudentCard.is_valid(uid):
|
||||||
|
raise forms.ValidationError(_("This UID is invalid"), code="invalid")
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class GetUserForm(forms.Form):
|
||||||
|
"""
|
||||||
|
The Form class aims at providing a valid user_id field in its cleaned data, in order to pass it to some view,
|
||||||
|
reverse function, or any other use.
|
||||||
|
|
||||||
|
The Form implements a nice JS widget allowing the user to type a customer account id, or search the database with
|
||||||
|
some nickname, first name, or last name (TODO)
|
||||||
|
"""
|
||||||
|
|
||||||
|
code = forms.CharField(
|
||||||
|
label="Code", max_length=StudentCard.UID_SIZE, required=False
|
||||||
|
)
|
||||||
|
id = AutoCompleteSelectField(
|
||||||
|
"users", required=False, label=_("Select user"), help_text=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_p(self):
|
||||||
|
self.fields["code"].widget.attrs["autofocus"] = True
|
||||||
|
return super(GetUserForm, self).as_p()
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super(GetUserForm, self).clean()
|
||||||
|
cus = None
|
||||||
|
if cleaned_data["code"] != "":
|
||||||
|
if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
|
||||||
|
card = StudentCard.objects.filter(uid=cleaned_data["code"])
|
||||||
|
if card.exists():
|
||||||
|
cus = card.first().customer
|
||||||
|
if cus is None:
|
||||||
|
cus = Customer.objects.filter(
|
||||||
|
account_id__iexact=cleaned_data["code"]
|
||||||
|
).first()
|
||||||
|
elif cleaned_data["id"] is not None:
|
||||||
|
cus = Customer.objects.filter(user=cleaned_data["id"]).first()
|
||||||
|
if cus is None or not cus.can_buy:
|
||||||
|
raise forms.ValidationError(_("User not found"))
|
||||||
|
cleaned_data["user_id"] = cus.user.id
|
||||||
|
cleaned_data["user"] = cus.user
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class RefillForm(forms.ModelForm):
|
||||||
|
error_css_class = "error"
|
||||||
|
required_css_class = "required"
|
||||||
|
amount = forms.FloatField(
|
||||||
|
min_value=0, widget=forms.NumberInput(attrs={"class": "focus"})
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Refilling
|
||||||
|
fields = ["amount", "payment_method", "bank"]
|
||||||
|
|
||||||
|
|
||||||
|
class CounterEditForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Counter
|
||||||
|
fields = ["sellers", "products"]
|
||||||
|
|
||||||
|
sellers = make_ajax_field(Counter, "sellers", "users", help_text="")
|
||||||
|
products = make_ajax_field(Counter, "products", "products", help_text="")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductEditForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Product
|
||||||
|
fields = [
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"product_type",
|
||||||
|
"code",
|
||||||
|
"parent_product",
|
||||||
|
"buying_groups",
|
||||||
|
"purchase_price",
|
||||||
|
"selling_price",
|
||||||
|
"special_selling_price",
|
||||||
|
"icon",
|
||||||
|
"club",
|
||||||
|
"limit_age",
|
||||||
|
"tray",
|
||||||
|
"archived",
|
||||||
|
]
|
||||||
|
|
||||||
|
parent_product = AutoCompleteSelectField(
|
||||||
|
"products", show_help_text=False, label=_("Parent product"), required=False
|
||||||
|
)
|
||||||
|
buying_groups = AutoCompleteSelectMultipleField(
|
||||||
|
"groups",
|
||||||
|
show_help_text=False,
|
||||||
|
help_text="",
|
||||||
|
label=_("Buying groups"),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
club = AutoCompleteSelectField("clubs", show_help_text=False)
|
||||||
|
counters = AutoCompleteSelectMultipleField(
|
||||||
|
"counters",
|
||||||
|
show_help_text=False,
|
||||||
|
help_text="",
|
||||||
|
label=_("Counters"),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(ProductEditForm, self).__init__(*args, **kwargs)
|
||||||
|
if self.instance.id:
|
||||||
|
self.fields["counters"].initial = [
|
||||||
|
str(c.id) for c in self.instance.counters.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
ret = super(ProductEditForm, self).save(*args, **kwargs)
|
||||||
|
if self.fields["counters"].initial:
|
||||||
|
for cid in self.fields["counters"].initial:
|
||||||
|
c = Counter.objects.filter(id=int(cid)).first()
|
||||||
|
c.products.remove(self.instance)
|
||||||
|
c.save()
|
||||||
|
for cid in self.cleaned_data["counters"]:
|
||||||
|
c = Counter.objects.filter(id=int(cid)).first()
|
||||||
|
c.products.add(self.instance)
|
||||||
|
c.save()
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class CashSummaryFormBase(forms.Form):
|
||||||
|
begin_date = TzAwareDateTimeField(label=_("Begin date"), required=False)
|
||||||
|
end_date = TzAwareDateTimeField(label=_("End date"), required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class EticketForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Eticket
|
||||||
|
fields = ["product", "banner", "event_title", "event_date"]
|
||||||
|
widgets = {"event_date": SelectDate}
|
||||||
|
|
||||||
|
product = AutoCompleteSelectField(
|
||||||
|
"products", show_help_text=False, label=_("Product"), required=True
|
||||||
|
)
|
62
counter/migrations/0019_billinginfo.py
Normal file
62
counter/migrations/0019_billinginfo.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Generated by Django 3.2.16 on 2023-01-08 12:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_countries.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("counter", "0018_producttype_priority"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="producttype",
|
||||||
|
options={"ordering": ["-priority", "name"], "verbose_name": "product type"},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="BillingInfo",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"first_name",
|
||||||
|
models.CharField(max_length=22, verbose_name="First name"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"last_name",
|
||||||
|
models.CharField(max_length=22, verbose_name="Last name"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"address_1",
|
||||||
|
models.CharField(max_length=50, verbose_name="Address 1"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"address_2",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, max_length=50, null=True, verbose_name="Address 2"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("zip_code", models.CharField(max_length=16, verbose_name="Zip code")),
|
||||||
|
("city", models.CharField(max_length=50, verbose_name="City")),
|
||||||
|
("country", django_countries.fields.CountryField(max_length=2)),
|
||||||
|
(
|
||||||
|
"customer",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="billing_infos",
|
||||||
|
to="counter.customer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@ -22,8 +22,8 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
|
||||||
from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.functions import Length
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -38,16 +38,20 @@ import string
|
|||||||
import os
|
import os
|
||||||
import base64
|
import base64
|
||||||
import datetime
|
import datetime
|
||||||
|
from dict2xml import dict2xml
|
||||||
|
|
||||||
|
from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB
|
||||||
from club.models import Club, Membership
|
from club.models import Club, Membership
|
||||||
from accounting.models import CurrencyField
|
from accounting.models import CurrencyField
|
||||||
from core.models import Group, User, Notification
|
from core.models import Group, User, Notification
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
from django_countries.fields import CountryField
|
||||||
|
|
||||||
|
|
||||||
class Customer(models.Model):
|
class Customer(models.Model):
|
||||||
"""
|
"""
|
||||||
This class extends a user to make a customer. It adds some basic customers informations, such as the accound ID, and
|
This class extends a user to make a customer. 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
|
is used by other accounting classes as reference to the customer, rather than using User
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -89,13 +93,28 @@ class Customer(models.Model):
|
|||||||
.subscription_end
|
.subscription_end
|
||||||
) < timedelta(days=90)
|
) < timedelta(days=90)
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def generate_account_id(number):
|
def new_for_user(cls, user: User):
|
||||||
number = str(number)
|
"""
|
||||||
letter = random.choice(string.ascii_lowercase)
|
Create a new Customer instance for the user given in parameter without saving it
|
||||||
while Customer.objects.filter(account_id=number + letter).exists():
|
The account if is automatically generated and the amount set at 0
|
||||||
letter = random.choice(string.ascii_lowercase)
|
"""
|
||||||
return number + letter
|
# account_id are 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
|
||||||
|
return cls(user=user, account_id="1504a", amount=0)
|
||||||
|
account_id = account_id["account_id"]
|
||||||
|
num = int(account_id[:-1])
|
||||||
|
while Customer.objects.filter(account_id=account_id).exists():
|
||||||
|
num += 1
|
||||||
|
account_id = str(num) + random.choice(string.ascii_lowercase)
|
||||||
|
|
||||||
|
return cls(user=user, account_id=account_id, amount=0)
|
||||||
|
|
||||||
def save(self, allow_negative=False, is_selling=False, *args, **kwargs):
|
def save(self, allow_negative=False, is_selling=False, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -122,6 +141,52 @@ class Customer(models.Model):
|
|||||||
return "".join(["https://", settings.SITH_URL, self.get_absolute_url()])
|
return "".join(["https://", settings.SITH_URL, self.get_absolute_url()])
|
||||||
|
|
||||||
|
|
||||||
|
class BillingInfo(models.Model):
|
||||||
|
"""
|
||||||
|
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"))
|
||||||
|
|
||||||
|
def to_3dsv2_xml(self) -> str:
|
||||||
|
"""
|
||||||
|
Convert the data from this model into a xml usable
|
||||||
|
by the online paying service of the Crédit Agricole bank.
|
||||||
|
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 = {
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.address_2:
|
||||||
|
data["Address"]["Address2"] = self.address_2
|
||||||
|
xml = dict2xml(data, wrap="Billing", newlines=False)
|
||||||
|
return '<?xml version="1.0" encoding="UTF-8" ?>' + xml
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.first_name} {self.last_name}"
|
||||||
|
|
||||||
|
|
||||||
class ProductType(models.Model):
|
class ProductType(models.Model):
|
||||||
"""
|
"""
|
||||||
This describes a product type
|
This describes a product type
|
||||||
@ -240,6 +305,10 @@ class Product(models.Model):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def profit(self):
|
||||||
|
return self.selling_price - self.purchase_price
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "%s (%s)" % (self.name, self.code)
|
return "%s (%s)" % (self.name, self.code)
|
||||||
|
|
||||||
@ -697,6 +766,12 @@ class Permanency(models.Model):
|
|||||||
self.end.strftime("%Y-%m-%d %H:%M:%S") if self.end else "",
|
self.end.strftime("%Y-%m-%d %H:%M:%S") if self.end else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration(self):
|
||||||
|
if self.end is None:
|
||||||
|
return self.activity - self.start
|
||||||
|
return self.end - self.start
|
||||||
|
|
||||||
|
|
||||||
class CashRegisterSummary(models.Model):
|
class CashRegisterSummary(models.Model):
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
|
267
counter/tests.py
267
counter/tests.py
@ -21,7 +21,7 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
@ -29,7 +29,7 @@ from django.urls import reverse
|
|||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from counter.models import Counter
|
from counter.models import Counter, Customer, BillingInfo
|
||||||
|
|
||||||
|
|
||||||
class CounterTest(TestCase):
|
class CounterTest(TestCase):
|
||||||
@ -67,7 +67,7 @@ class CounterTest(TestCase):
|
|||||||
response = self.client.get(response.get("location"))
|
response = self.client.get(response.get("location"))
|
||||||
self.assertTrue(">Richard Batsbak</" in str(response.content))
|
self.assertTrue(">Richard Batsbak</" in str(response.content))
|
||||||
|
|
||||||
response = self.client.post(
|
self.client.post(
|
||||||
location,
|
location,
|
||||||
{
|
{
|
||||||
"action": "refill",
|
"action": "refill",
|
||||||
@ -76,15 +76,11 @@ class CounterTest(TestCase):
|
|||||||
"bank": "OTHER",
|
"bank": "OTHER",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response = self.client.post(location, {"action": "code", "code": "BARB"})
|
self.client.post(location, {"action": "code", "code": "BARB"})
|
||||||
response = self.client.post(
|
self.client.post(location, {"action": "add_product", "product_id": "4"})
|
||||||
location, {"action": "add_product", "product_id": "4"}
|
self.client.post(location, {"action": "del_product", "product_id": "4"})
|
||||||
)
|
self.client.post(location, {"action": "code", "code": "2xdeco"})
|
||||||
response = self.client.post(
|
self.client.post(location, {"action": "code", "code": "1xbarb"})
|
||||||
location, {"action": "del_product", "product_id": "4"}
|
|
||||||
)
|
|
||||||
response = self.client.post(location, {"action": "code", "code": "2xdeco"})
|
|
||||||
response = self.client.post(location, {"action": "code", "code": "1xbarb"})
|
|
||||||
response = self.client.post(location, {"action": "code", "code": "fin"})
|
response = self.client.post(location, {"action": "code", "code": "fin"})
|
||||||
|
|
||||||
response_get = self.client.get(response.get("location"))
|
response_get = self.client.get(response.get("location"))
|
||||||
@ -96,7 +92,7 @@ class CounterTest(TestCase):
|
|||||||
in str(response_content)
|
in str(response_content)
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.post(
|
self.client.post(
|
||||||
reverse("counter:login", kwargs={"counter_id": self.mde.id}),
|
reverse("counter:login", kwargs={"counter_id": self.mde.id}),
|
||||||
{"username": self.sli.username, "password": "plop"},
|
{"username": self.sli.username, "password": "plop"},
|
||||||
)
|
)
|
||||||
@ -154,6 +150,234 @@ class CounterStatsTest(TestCase):
|
|||||||
self.assertTrue(response.status_code == 403)
|
self.assertTrue(response.status_code == 403)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingInfoTest(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.payload_1 = {
|
||||||
|
"first_name": "Subscribed",
|
||||||
|
"last_name": "User",
|
||||||
|
"address_1": "1 rue des Huns",
|
||||||
|
"zip_code": "90000",
|
||||||
|
"city": "Belfort",
|
||||||
|
"country": "FR",
|
||||||
|
}
|
||||||
|
cls.payload_2 = {
|
||||||
|
"first_name": "Subscribed",
|
||||||
|
"last_name": "User",
|
||||||
|
"address_1": "3, rue de Troyes",
|
||||||
|
"zip_code": "34301",
|
||||||
|
"city": "Sète",
|
||||||
|
"country": "FR",
|
||||||
|
}
|
||||||
|
super().setUpClass()
|
||||||
|
call_command("populate")
|
||||||
|
|
||||||
|
def test_edit_infos(self):
|
||||||
|
user = User.objects.get(username="subscriber")
|
||||||
|
BillingInfo.objects.get_or_create(
|
||||||
|
customer=user.customer, defaults=self.payload_1
|
||||||
|
)
|
||||||
|
self.client.login(username=user.username, password="plop")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("counter:edit_billing_info", args=[user.id]),
|
||||||
|
json.dumps(self.payload_2),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
user = User.objects.get(username="subscriber")
|
||||||
|
infos = BillingInfo.objects.get(customer__user=user)
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertJSONEqual(response.content, {"errors": None})
|
||||||
|
self.assertTrue(hasattr(user.customer, "billing_infos"))
|
||||||
|
self.assertEqual(user.customer, infos.customer)
|
||||||
|
self.assertEqual("Subscribed", infos.first_name)
|
||||||
|
self.assertEqual("User", infos.last_name)
|
||||||
|
self.assertEqual("3, rue de Troyes", infos.address_1)
|
||||||
|
self.assertEqual(None, infos.address_2)
|
||||||
|
self.assertEqual("34301", infos.zip_code)
|
||||||
|
self.assertEqual("Sète", infos.city)
|
||||||
|
self.assertEqual("FR", infos.country)
|
||||||
|
|
||||||
|
def test_create_infos_for_user_with_account(self):
|
||||||
|
user = User.objects.get(username="subscriber")
|
||||||
|
if hasattr(user.customer, "billing_infos"):
|
||||||
|
user.customer.billing_infos.delete()
|
||||||
|
self.client.login(username=user.username, password="plop")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("counter:create_billing_info", args=[user.id]),
|
||||||
|
json.dumps(self.payload_1),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
user = User.objects.get(username="subscriber")
|
||||||
|
infos = BillingInfo.objects.get(customer__user=user)
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertJSONEqual(response.content, {"errors": None})
|
||||||
|
self.assertTrue(hasattr(user.customer, "billing_infos"))
|
||||||
|
self.assertEqual(user.customer, infos.customer)
|
||||||
|
self.assertEqual("Subscribed", infos.first_name)
|
||||||
|
self.assertEqual("User", infos.last_name)
|
||||||
|
self.assertEqual("1 rue des Huns", infos.address_1)
|
||||||
|
self.assertEqual(None, infos.address_2)
|
||||||
|
self.assertEqual("90000", infos.zip_code)
|
||||||
|
self.assertEqual("Belfort", infos.city)
|
||||||
|
self.assertEqual("FR", infos.country)
|
||||||
|
|
||||||
|
def test_create_infos_for_user_without_account(self):
|
||||||
|
user = User.objects.get(username="subscriber")
|
||||||
|
if hasattr(user, "customer"):
|
||||||
|
user.customer.delete()
|
||||||
|
self.client.login(username=user.username, password="plop")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("counter:create_billing_info", args=[user.id]),
|
||||||
|
json.dumps(self.payload_1),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
user = User.objects.get(username="subscriber")
|
||||||
|
self.assertTrue(hasattr(user, "customer"))
|
||||||
|
self.assertTrue(hasattr(user.customer, "billing_infos"))
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertJSONEqual(response.content, {"errors": None})
|
||||||
|
infos = BillingInfo.objects.get(customer__user=user)
|
||||||
|
self.assertEqual(user.customer, infos.customer)
|
||||||
|
self.assertEqual("Subscribed", infos.first_name)
|
||||||
|
self.assertEqual("User", infos.last_name)
|
||||||
|
self.assertEqual("1 rue des Huns", infos.address_1)
|
||||||
|
self.assertEqual(None, infos.address_2)
|
||||||
|
self.assertEqual("90000", infos.zip_code)
|
||||||
|
self.assertEqual("Belfort", infos.city)
|
||||||
|
self.assertEqual("FR", infos.country)
|
||||||
|
|
||||||
|
def test_create_invalid(self):
|
||||||
|
user = User.objects.get(username="subscriber")
|
||||||
|
if hasattr(user.customer, "billing_infos"):
|
||||||
|
user.customer.billing_infos.delete()
|
||||||
|
self.client.login(username=user.username, password="plop")
|
||||||
|
# address_1, zip_code and country are missing
|
||||||
|
payload = {
|
||||||
|
"first_name": user.first_name,
|
||||||
|
"last_name": user.last_name,
|
||||||
|
"city": "Belfort",
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("counter:create_billing_info", args=[user.id]),
|
||||||
|
json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
user = User.objects.get(username="subscriber")
|
||||||
|
self.assertEqual(400, response.status_code)
|
||||||
|
self.assertFalse(hasattr(user.customer, "billing_infos"))
|
||||||
|
expected_errors = {
|
||||||
|
"errors": [
|
||||||
|
{"field": "Adresse 1", "messages": ["Ce champ est obligatoire."]},
|
||||||
|
{"field": "Code postal", "messages": ["Ce champ est obligatoire."]},
|
||||||
|
{"field": "Country", "messages": ["Ce champ est obligatoire."]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
self.assertJSONEqual(response.content, expected_errors)
|
||||||
|
|
||||||
|
def test_edit_invalid(self):
|
||||||
|
user = User.objects.get(username="subscriber")
|
||||||
|
BillingInfo.objects.get_or_create(
|
||||||
|
customer=user.customer, defaults=self.payload_1
|
||||||
|
)
|
||||||
|
self.client.login(username=user.username, password="plop")
|
||||||
|
# address_1, zip_code and country are missing
|
||||||
|
payload = {
|
||||||
|
"first_name": user.first_name,
|
||||||
|
"last_name": user.last_name,
|
||||||
|
"city": "Belfort",
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("counter:edit_billing_info", args=[user.id]),
|
||||||
|
json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
user = User.objects.get(username="subscriber")
|
||||||
|
self.assertEqual(400, response.status_code)
|
||||||
|
self.assertTrue(hasattr(user.customer, "billing_infos"))
|
||||||
|
expected_errors = {
|
||||||
|
"errors": [
|
||||||
|
{"field": "Adresse 1", "messages": ["Ce champ est obligatoire."]},
|
||||||
|
{"field": "Code postal", "messages": ["Ce champ est obligatoire."]},
|
||||||
|
{"field": "Country", "messages": ["Ce champ est obligatoire."]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
self.assertJSONEqual(response.content, expected_errors)
|
||||||
|
|
||||||
|
def test_edit_other_user(self):
|
||||||
|
user = User.objects.get(username="sli")
|
||||||
|
self.client.login(username="subscriber", password="plop")
|
||||||
|
BillingInfo.objects.get_or_create(
|
||||||
|
customer=user.customer, defaults=self.payload_1
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("counter:edit_billing_info", args=[user.id]),
|
||||||
|
json.dumps(self.payload_2),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(403, response.status_code)
|
||||||
|
|
||||||
|
def test_edit_not_existing_infos(self):
|
||||||
|
user = User.objects.get(username="subscriber")
|
||||||
|
if hasattr(user.customer, "billing_infos"):
|
||||||
|
user.customer.billing_infos.delete()
|
||||||
|
self.client.login(username=user.username, password="plop")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("counter:edit_billing_info", args=[user.id]),
|
||||||
|
json.dumps(self.payload_2),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(404, response.status_code)
|
||||||
|
|
||||||
|
def test_edit_by_root(self):
|
||||||
|
user = User.objects.get(username="subscriber")
|
||||||
|
BillingInfo.objects.get_or_create(
|
||||||
|
customer=user.customer, defaults=self.payload_1
|
||||||
|
)
|
||||||
|
self.client.login(username="root", password="plop")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("counter:edit_billing_info", args=[user.id]),
|
||||||
|
json.dumps(self.payload_2),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
user = User.objects.get(username="subscriber")
|
||||||
|
infos = BillingInfo.objects.get(customer__user=user)
|
||||||
|
self.assertJSONEqual(response.content, {"errors": None})
|
||||||
|
self.assertTrue(hasattr(user.customer, "billing_infos"))
|
||||||
|
self.assertEqual(user.customer, infos.customer)
|
||||||
|
self.assertEqual("Subscribed", infos.first_name)
|
||||||
|
self.assertEqual("User", infos.last_name)
|
||||||
|
self.assertEqual("3, rue de Troyes", infos.address_1)
|
||||||
|
self.assertEqual(None, infos.address_2)
|
||||||
|
self.assertEqual("34301", infos.zip_code)
|
||||||
|
self.assertEqual("Sète", infos.city)
|
||||||
|
self.assertEqual("FR", infos.country)
|
||||||
|
|
||||||
|
def test_create_by_root(self):
|
||||||
|
user = User.objects.get(username="subscriber")
|
||||||
|
if hasattr(user.customer, "billing_infos"):
|
||||||
|
user.customer.billing_infos.delete()
|
||||||
|
self.client.login(username="root", password="plop")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("counter:create_billing_info", args=[user.id]),
|
||||||
|
json.dumps(self.payload_2),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
user = User.objects.get(username="subscriber")
|
||||||
|
infos = BillingInfo.objects.get(customer__user=user)
|
||||||
|
self.assertJSONEqual(response.content, {"errors": None})
|
||||||
|
self.assertTrue(hasattr(user.customer, "billing_infos"))
|
||||||
|
self.assertEqual(user.customer, infos.customer)
|
||||||
|
self.assertEqual("Subscribed", infos.first_name)
|
||||||
|
self.assertEqual("User", infos.last_name)
|
||||||
|
self.assertEqual("3, rue de Troyes", infos.address_1)
|
||||||
|
self.assertEqual(None, infos.address_2)
|
||||||
|
self.assertEqual("34301", infos.zip_code)
|
||||||
|
self.assertEqual("Sète", infos.city)
|
||||||
|
self.assertEqual("FR", infos.country)
|
||||||
|
|
||||||
|
|
||||||
class BarmanConnectionTest(TestCase):
|
class BarmanConnectionTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
call_command("populate")
|
call_command("populate")
|
||||||
@ -519,3 +743,20 @@ class StudentCardTest(TestCase):
|
|||||||
{"uid": "8B90734A802A8F"},
|
{"uid": "8B90734A802A8F"},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountIdTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
user_a = User.objects.create(username="a", password="plop", email="a.a@a.fr")
|
||||||
|
user_b = User.objects.create(username="b", password="plop", email="b.b@b.fr")
|
||||||
|
user_c = User.objects.create(username="c", password="plop", email="c.c@c.fr")
|
||||||
|
Customer.objects.create(user=user_a, amount=0, account_id="1111a")
|
||||||
|
Customer.objects.create(user=user_b, amount=0, account_id="9999z")
|
||||||
|
Customer.objects.create(user=user_c, amount=0, account_id="12345f")
|
||||||
|
|
||||||
|
def test_create_customer(self):
|
||||||
|
user_d = User.objects.create(username="d", password="plop")
|
||||||
|
customer_d = Customer.new_for_user(user_d)
|
||||||
|
customer_d.save()
|
||||||
|
number = customer_d.account_id[:-1]
|
||||||
|
self.assertEqual(number, "12346")
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
|
||||||
from django.urls import re_path
|
from django.urls import re_path, path
|
||||||
|
|
||||||
from counter.views import *
|
from counter.views import *
|
||||||
|
|
||||||
@ -66,6 +66,16 @@ urlpatterns = [
|
|||||||
StudentCardDeleteView.as_view(),
|
StudentCardDeleteView.as_view(),
|
||||||
name="delete_student_card",
|
name="delete_student_card",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"customer/<int:user_id>/billing_info/create",
|
||||||
|
create_billing_info,
|
||||||
|
name="create_billing_info",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"customer/<int:user_id>/billing_info/edit",
|
||||||
|
edit_billing_info,
|
||||||
|
name="edit_billing_info",
|
||||||
|
),
|
||||||
re_path(r"^admin/(?P<counter_id>[0-9]+)$", CounterEditView.as_view(), name="admin"),
|
re_path(r"^admin/(?P<counter_id>[0-9]+)$", CounterEditView.as_view(), name="admin"),
|
||||||
re_path(
|
re_path(
|
||||||
r"^admin/(?P<counter_id>[0-9]+)/prop$",
|
r"^admin/(?P<counter_id>[0-9]+)/prop$",
|
||||||
|
231
counter/views.py
231
counter/views.py
@ -21,10 +21,13 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
from django.views.generic import ListView, DetailView, RedirectView, TemplateView
|
from django.views.generic import ListView, DetailView, RedirectView, TemplateView
|
||||||
from django.views.generic.base import View
|
from django.views.generic.base import View
|
||||||
from django.views.generic.edit import (
|
from django.views.generic.edit import (
|
||||||
@ -49,12 +52,20 @@ import re
|
|||||||
import pytz
|
import pytz
|
||||||
from datetime import date, timedelta, datetime
|
from datetime import date, timedelta, datetime
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
|
|
||||||
from ajax_select import make_ajax_field
|
|
||||||
|
|
||||||
from core.views import CanViewMixin, TabedViewMixin, CanEditMixin
|
from core.views import CanViewMixin, TabedViewMixin, CanEditMixin
|
||||||
from core.views.forms import LoginForm, SelectDate, SelectDateTime
|
from core.views.forms import LoginForm
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
from counter.forms import (
|
||||||
|
BillingInfoForm,
|
||||||
|
StudentCardForm,
|
||||||
|
GetUserForm,
|
||||||
|
RefillForm,
|
||||||
|
CounterEditForm,
|
||||||
|
ProductEditForm,
|
||||||
|
CashSummaryFormBase,
|
||||||
|
EticketForm,
|
||||||
|
)
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
@ -68,9 +79,9 @@ from counter.models import (
|
|||||||
CashRegisterSummaryItem,
|
CashRegisterSummaryItem,
|
||||||
Eticket,
|
Eticket,
|
||||||
Permanency,
|
Permanency,
|
||||||
|
BillingInfo,
|
||||||
)
|
)
|
||||||
from accounting.models import CurrencyField
|
from accounting.models import CurrencyField
|
||||||
from core.views.forms import TzAwareDateTimeField
|
|
||||||
|
|
||||||
|
|
||||||
class CounterAdminMixin(View):
|
class CounterAdminMixin(View):
|
||||||
@ -103,24 +114,6 @@ class CounterAdminMixin(View):
|
|||||||
return super(CounterAdminMixin, self).dispatch(request, *args, **kwargs)
|
return super(CounterAdminMixin, self).dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class StudentCardForm(forms.ModelForm):
|
|
||||||
"""
|
|
||||||
Form for adding student cards
|
|
||||||
Only used for user profile since CounterClick is to complicated
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = StudentCard
|
|
||||||
fields = ["uid"]
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
cleaned_data = super(StudentCardForm, self).clean()
|
|
||||||
uid = cleaned_data.get("uid", None)
|
|
||||||
if not uid or not StudentCard.is_valid(uid):
|
|
||||||
raise forms.ValidationError(_("This UID is invalid"), code="invalid")
|
|
||||||
return cleaned_data
|
|
||||||
|
|
||||||
|
|
||||||
class StudentCardDeleteView(DeleteView, CanEditMixin):
|
class StudentCardDeleteView(DeleteView, CanEditMixin):
|
||||||
"""
|
"""
|
||||||
View used to delete a card from a user
|
View used to delete a card from a user
|
||||||
@ -140,59 +133,6 @@ class StudentCardDeleteView(DeleteView, CanEditMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class GetUserForm(forms.Form):
|
|
||||||
"""
|
|
||||||
The Form class aims at providing a valid user_id field in its cleaned data, in order to pass it to some view,
|
|
||||||
reverse function, or any other use.
|
|
||||||
|
|
||||||
The Form implements a nice JS widget allowing the user to type a customer account id, or search the database with
|
|
||||||
some nickname, first name, or last name (TODO)
|
|
||||||
"""
|
|
||||||
|
|
||||||
code = forms.CharField(
|
|
||||||
label="Code", max_length=StudentCard.UID_SIZE, required=False
|
|
||||||
)
|
|
||||||
id = AutoCompleteSelectField(
|
|
||||||
"users", required=False, label=_("Select user"), help_text=None
|
|
||||||
)
|
|
||||||
|
|
||||||
def as_p(self):
|
|
||||||
self.fields["code"].widget.attrs["autofocus"] = True
|
|
||||||
return super(GetUserForm, self).as_p()
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
cleaned_data = super(GetUserForm, self).clean()
|
|
||||||
cus = None
|
|
||||||
if cleaned_data["code"] != "":
|
|
||||||
if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
|
|
||||||
card = StudentCard.objects.filter(uid=cleaned_data["code"])
|
|
||||||
if card.exists():
|
|
||||||
cus = card.first().customer
|
|
||||||
if cus is None:
|
|
||||||
cus = Customer.objects.filter(
|
|
||||||
account_id__iexact=cleaned_data["code"]
|
|
||||||
).first()
|
|
||||||
elif cleaned_data["id"] is not None:
|
|
||||||
cus = Customer.objects.filter(user=cleaned_data["id"]).first()
|
|
||||||
if cus is None or not cus.can_buy:
|
|
||||||
raise forms.ValidationError(_("User not found"))
|
|
||||||
cleaned_data["user_id"] = cus.user.id
|
|
||||||
cleaned_data["user"] = cus.user
|
|
||||||
return cleaned_data
|
|
||||||
|
|
||||||
|
|
||||||
class RefillForm(forms.ModelForm):
|
|
||||||
error_css_class = "error"
|
|
||||||
required_css_class = "required"
|
|
||||||
amount = forms.FloatField(
|
|
||||||
min_value=0, widget=forms.NumberInput(attrs={"class": "focus"})
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Refilling
|
|
||||||
fields = ["amount", "payment_method", "bank"]
|
|
||||||
|
|
||||||
|
|
||||||
class CounterTabsMixin(TabedViewMixin):
|
class CounterTabsMixin(TabedViewMixin):
|
||||||
def get_tabs_title(self):
|
def get_tabs_title(self):
|
||||||
if hasattr(self.object, "stock_owner"):
|
if hasattr(self.object, "stock_owner"):
|
||||||
@ -867,15 +807,6 @@ class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
|
|||||||
current_tab = "counters"
|
current_tab = "counters"
|
||||||
|
|
||||||
|
|
||||||
class CounterEditForm(forms.ModelForm):
|
|
||||||
class Meta:
|
|
||||||
model = Counter
|
|
||||||
fields = ["sellers", "products"]
|
|
||||||
|
|
||||||
sellers = make_ajax_field(Counter, "sellers", "users", help_text="")
|
|
||||||
products = make_ajax_field(Counter, "products", "products", help_text="")
|
|
||||||
|
|
||||||
|
|
||||||
class CounterEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
class CounterEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||||
"""
|
"""
|
||||||
Edit a counter's main informations (for the counter's manager)
|
Edit a counter's main informations (for the counter's manager)
|
||||||
@ -995,66 +926,6 @@ class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
|||||||
current_tab = "products"
|
current_tab = "products"
|
||||||
|
|
||||||
|
|
||||||
class ProductEditForm(forms.ModelForm):
|
|
||||||
class Meta:
|
|
||||||
model = Product
|
|
||||||
fields = [
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"product_type",
|
|
||||||
"code",
|
|
||||||
"parent_product",
|
|
||||||
"buying_groups",
|
|
||||||
"purchase_price",
|
|
||||||
"selling_price",
|
|
||||||
"special_selling_price",
|
|
||||||
"icon",
|
|
||||||
"club",
|
|
||||||
"limit_age",
|
|
||||||
"tray",
|
|
||||||
"archived",
|
|
||||||
]
|
|
||||||
|
|
||||||
parent_product = AutoCompleteSelectField(
|
|
||||||
"products", show_help_text=False, label=_("Parent product"), required=False
|
|
||||||
)
|
|
||||||
buying_groups = AutoCompleteSelectMultipleField(
|
|
||||||
"groups",
|
|
||||||
show_help_text=False,
|
|
||||||
help_text="",
|
|
||||||
label=_("Buying groups"),
|
|
||||||
required=True,
|
|
||||||
)
|
|
||||||
club = AutoCompleteSelectField("clubs", show_help_text=False)
|
|
||||||
counters = AutoCompleteSelectMultipleField(
|
|
||||||
"counters",
|
|
||||||
show_help_text=False,
|
|
||||||
help_text="",
|
|
||||||
label=_("Counters"),
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super(ProductEditForm, self).__init__(*args, **kwargs)
|
|
||||||
if self.instance.id:
|
|
||||||
self.fields["counters"].initial = [
|
|
||||||
str(c.id) for c in self.instance.counters.all()
|
|
||||||
]
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
ret = super(ProductEditForm, self).save(*args, **kwargs)
|
|
||||||
if self.fields["counters"].initial:
|
|
||||||
for cid in self.fields["counters"].initial:
|
|
||||||
c = Counter.objects.filter(id=int(cid)).first()
|
|
||||||
c.products.remove(self.instance)
|
|
||||||
c.save()
|
|
||||||
for cid in self.cleaned_data["counters"]:
|
|
||||||
c = Counter.objects.filter(id=int(cid)).first()
|
|
||||||
c.products.add(self.instance)
|
|
||||||
c.save()
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
||||||
"""
|
"""
|
||||||
A create view for the admins
|
A create view for the admins
|
||||||
@ -1482,7 +1353,7 @@ class CounterStatView(DetailView, CounterAdminMixin):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
"""Add stats to the context"""
|
"""Add stats to the context"""
|
||||||
from django.db.models import Sum, Case, When, F, DecimalField
|
from django.db.models import Sum, Case, When, F
|
||||||
|
|
||||||
kwargs = super(CounterStatView, self).get_context_data(**kwargs)
|
kwargs = super(CounterStatView, self).get_context_data(**kwargs)
|
||||||
kwargs["Customer"] = Customer
|
kwargs["Customer"] = Customer
|
||||||
@ -1585,11 +1456,6 @@ class CashSummaryEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
|||||||
return reverse("counter:cash_summary_list")
|
return reverse("counter:cash_summary_list")
|
||||||
|
|
||||||
|
|
||||||
class CashSummaryFormBase(forms.Form):
|
|
||||||
begin_date = TzAwareDateTimeField(label=_("Begin date"), required=False)
|
|
||||||
end_date = TzAwareDateTimeField(label=_("End date"), required=False)
|
|
||||||
|
|
||||||
|
|
||||||
class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||||
"""Display a list of cash summaries"""
|
"""Display a list of cash summaries"""
|
||||||
|
|
||||||
@ -1669,7 +1535,7 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
|
|||||||
end_date = (start_date + timedelta(days=32)).replace(
|
end_date = (start_date + timedelta(days=32)).replace(
|
||||||
day=1, hour=0, minute=0, microsecond=0
|
day=1, hour=0, minute=0, microsecond=0
|
||||||
)
|
)
|
||||||
from django.db.models import Sum, Case, When, F, DecimalField
|
from django.db.models import Sum, Case, When, F
|
||||||
|
|
||||||
kwargs["sum_cb"] = sum(
|
kwargs["sum_cb"] = sum(
|
||||||
[
|
[
|
||||||
@ -1725,17 +1591,6 @@ class EticketListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
|||||||
current_tab = "etickets"
|
current_tab = "etickets"
|
||||||
|
|
||||||
|
|
||||||
class EticketForm(forms.ModelForm):
|
|
||||||
class Meta:
|
|
||||||
model = Eticket
|
|
||||||
fields = ["product", "banner", "event_title", "event_date"]
|
|
||||||
widgets = {"event_date": SelectDate}
|
|
||||||
|
|
||||||
product = AutoCompleteSelectField(
|
|
||||||
"products", show_help_text=False, label=_("Product"), required=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EticketCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
class EticketCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
||||||
"""
|
"""
|
||||||
Create an eticket
|
Create an eticket
|
||||||
@ -1895,3 +1750,55 @@ class StudentCardFormView(FormView):
|
|||||||
return reverse_lazy(
|
return reverse_lazy(
|
||||||
"core:user_prefs", kwargs={"user_id": self.customer.user.pk}
|
"core:user_prefs", kwargs={"user_id": self.customer.user.pk}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def __manage_billing_info_req(request, user_id, delete_if_fail=False):
|
||||||
|
data = json.loads(request.body)
|
||||||
|
form = BillingInfoForm(data)
|
||||||
|
if not form.is_valid():
|
||||||
|
if delete_if_fail:
|
||||||
|
Customer.objects.get(user__id=user_id).billing_infos.delete()
|
||||||
|
errors = [
|
||||||
|
{"field": str(form.fields[k].label), "messages": v}
|
||||||
|
for k, v in form.errors.items()
|
||||||
|
]
|
||||||
|
content = json.dumps({"errors": errors})
|
||||||
|
return HttpResponse(status=400, content=content)
|
||||||
|
if form.is_valid():
|
||||||
|
infos = Customer.objects.get(user__id=user_id).billing_infos
|
||||||
|
for field in form.fields:
|
||||||
|
infos.__dict__[field] = form[field].value()
|
||||||
|
infos.save()
|
||||||
|
content = json.dumps({"errors": None})
|
||||||
|
return HttpResponse(status=200, content=content)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def create_billing_info(request, user_id):
|
||||||
|
user = request.user
|
||||||
|
if user.id != user_id and not user.has_perm("counter:add_billinginfo"):
|
||||||
|
raise PermissionDenied()
|
||||||
|
user = get_object_or_404(User, pk=user_id)
|
||||||
|
if not hasattr(user, "customer"):
|
||||||
|
customer = Customer.new_for_user(user)
|
||||||
|
customer.save()
|
||||||
|
else:
|
||||||
|
customer = get_object_or_404(Customer, user_id=user_id)
|
||||||
|
BillingInfo.objects.create(customer=customer)
|
||||||
|
return __manage_billing_info_req(request, user_id, True)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def edit_billing_info(request, user_id):
|
||||||
|
user = request.user
|
||||||
|
if user.id != user_id and not user.has_perm("counter:change_billinginfo"):
|
||||||
|
raise PermissionDenied()
|
||||||
|
user = get_object_or_404(User, pk=user_id)
|
||||||
|
if not hasattr(user, "customer"):
|
||||||
|
raise Http404
|
||||||
|
if not hasattr(user.customer, "billing_infos"):
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
return __manage_billing_info_req(request, user_id)
|
||||||
|
@ -10,7 +10,7 @@ Pourquoi réécrire le site
|
|||||||
|
|
||||||
L'ancienne version du site, sobrement baptisée `ae2 <https://github.com/ae-utbm/sith2>`_ présentait un nombre impressionnant de fonctionnalités. Il avait été écrit en PHP et se basait sur son propre framework maison.
|
L'ancienne version du site, sobrement baptisée `ae2 <https://github.com/ae-utbm/sith2>`_ présentait un nombre impressionnant de fonctionnalités. Il avait été écrit en PHP et se basait sur son propre framework maison.
|
||||||
|
|
||||||
Malheureusement, son entretiens était plus ou moins hasardeux et son framework reposait sur des principes assez différents de ce qui se fait aujourd'hui, rendant la maintenance difficile. De plus, la version de PHP qu'il utilisait était plus que déprécié et à l'heure de l'arrivée de PHP 7 et de sa non rétrocompatibilité il était vital de faire quelque chose. Il a donc été décidé de le réécrire.
|
Malheureusement, son entretien était plus ou moins hasardeux et son framework reposait sur des principes assez différents de ce qui se fait aujourd'hui, rendant la maintenance difficile. De plus, la version de PHP qu'il utilisait était plus que dépréciée et à l'heure de l'arrivée de PHP 7 et de sa non rétrocompatibilité il était vital de faire quelque chose. Il a donc été décidé de le réécrire.
|
||||||
|
|
||||||
La philosophie du projet
|
La philosophie du projet
|
||||||
------------------------
|
------------------------
|
||||||
|
@ -15,6 +15,13 @@ L'écosystème Javascript étant à peine naissant et les frameworks allant et v
|
|||||||
|
|
||||||
Ne restait plus que le Python et le Ruby avec les frameworks Django et Ruby On Rails. Ruby ayant une réputation d'être très "cutting edge", c'est Python, un langage bien implanté et ayant fait ses preuves, qui a été retenu.
|
Ne restait plus que le Python et le Ruby avec les frameworks Django et Ruby On Rails. Ruby ayant une réputation d'être très "cutting edge", c'est Python, un langage bien implanté et ayant fait ses preuves, qui a été retenu.
|
||||||
|
|
||||||
|
Il est à noter que réécrire le site avec un framework PHP comme Laravel ou Symphony
|
||||||
|
eut aussi été possible, ces deux technologies étant assez matures et robustes
|
||||||
|
au moment où le développement a commencé.
|
||||||
|
Cependant, il aurait été potentiellemet fastidieux de maintenir en parallèle deux
|
||||||
|
versions de PHP sur le serveur durant toute la durée du développement.
|
||||||
|
Il faut aussi prendre en compte que nous étions à ce moment dégoûtés du PHP.
|
||||||
|
|
||||||
Backend
|
Backend
|
||||||
-------
|
-------
|
||||||
|
|
||||||
@ -27,7 +34,7 @@ Le python est un langage de programmation interprété multi paradigme sorti en
|
|||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
Puisque toutes les dépendances du backend sont des packages Python, elles sont toutes ajoutées directement dans le fichier **requirements.txt** à la racine du projet.
|
Puisque toutes les dépendances du backend sont des packages Python, elles sont toutes ajoutées directement dans le fichier **pyproject.toml** à la racine du projet.
|
||||||
|
|
||||||
Django
|
Django
|
||||||
~~~~~~
|
~~~~~~
|
||||||
@ -37,7 +44,10 @@ Django
|
|||||||
|
|
||||||
Django est un framework web pour Python apparu en 2005. Il fourni un grand nombre de fonctionnalités pour développer un site rapidement et simplement. Cela inclu entre autre un serveur Web de développement, un parseur d'URLs pour le routage des différentes URI du site, un ORM (Object-Relational Mapper) pour la gestion de la base de donnée ainsi qu'un moteur de templates pour le rendu HTML. Django propose une version LTS (Long Term Support) qui reste stable et est maintenu sur des cycles plus longs, ce sont ces versions qui sont utilisées.
|
Django est un framework web pour Python apparu en 2005. Il fourni un grand nombre de fonctionnalités pour développer un site rapidement et simplement. Cela inclu entre autre un serveur Web de développement, un parseur d'URLs pour le routage des différentes URI du site, un ORM (Object-Relational Mapper) pour la gestion de la base de donnée ainsi qu'un moteur de templates pour le rendu HTML. Django propose une version LTS (Long Term Support) qui reste stable et est maintenu sur des cycles plus longs, ce sont ces versions qui sont utilisées.
|
||||||
|
|
||||||
PostgreSQL / SQLite
|
Il est possible que la version de Django utilisée ne soit pas la plus récente.
|
||||||
|
En effet, la version de Django utilisée est systématiquement celle munie d'un support au long-terme.
|
||||||
|
|
||||||
|
PostgreSQL / SQLite3
|
||||||
~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
| `Site officiel PostgreSQL <https://www.postgresql.org/>`__
|
| `Site officiel PostgreSQL <https://www.postgresql.org/>`__
|
||||||
@ -48,7 +58,7 @@ Comme la majorité des sites internet, le Sith de l'AE enregistre ses données d
|
|||||||
Le principal à retenir ici est :
|
Le principal à retenir ici est :
|
||||||
|
|
||||||
* Sur la version de production nous utilisons PostgreSQL, c'est cette version qui doit fonctionner en priorité
|
* Sur la version de production nous utilisons PostgreSQL, c'est cette version qui doit fonctionner en priorité
|
||||||
* Sur les versions de développement, pour faciliter l'installation du projet, nous utilisons la technologie SQLite qui ne requiert aucune installation spécifique. Certaines instructions ne sont pas supportées par cette technologie et il est parfois nécessaire d'installer PostgreSQL pour le développement de certaines parties du site.
|
* Sur les versions de développement, pour faciliter l'installation du projet, nous utilisons la technologie SQLite3 qui ne requiert aucune installation spécifique (La librairie `sqlite` est incluse dans les librairies par défaut de Python). Certaines instructions ne sont pas supportées par cette technologie et il est parfois nécessaire d'installer PostgreSQL pour le développement de certaines parties du site.
|
||||||
|
|
||||||
Frontend
|
Frontend
|
||||||
--------
|
--------
|
||||||
@ -82,7 +92,39 @@ jQuery
|
|||||||
|
|
||||||
jQuery est une bibliothèque JavaScript libre et multiplateforme créée pour faciliter l'écriture de scripts côté client dans le code HTML des pages web. La première version est lancée en janvier 2006 par John Resig.
|
jQuery est une bibliothèque JavaScript libre et multiplateforme créée pour faciliter l'écriture de scripts côté client dans le code HTML des pages web. La première version est lancée en janvier 2006 par John Resig.
|
||||||
|
|
||||||
C'est une vieille technologie et certains feront remarquer à juste titre que le Javascript moderne permet d'utiliser assez simplement la majorité de ce que fourni jQuery sans rien avoir à installer. Cependant, de nombreuses dépendances du projet utilisent encore jQuery qui est toujours très implanté aujourd'hui. Le sucre syntaxique qu'offre cette libraire reste très agréable à utiliser et économise parfois beaucoup de temps. Ça fonctionne et ça fonctionne très bien. C'est maintenu, léger et pratique, il n'y a pas de raison particulière de s'en séparer.
|
C'est une vieille technologie et certains feront remarquer à juste titre que le Javascript moderne permet d'utiliser assez simplement la majorité de ce que fournit jQuery sans rien avoir à installer. Cependant, de nombreuses dépendances du projet utilisent encore jQuery qui est toujours très implanté aujourd'hui. Le sucre syntaxique qu'offre cette librairie reste très agréable à utiliser et économise parfois beaucoup de temps. Ça fonctionne et ça fonctionne très bien. C'est maintenu et pratique.
|
||||||
|
|
||||||
|
VueJS
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
`Site officiel <https://vuejs.org/>`__
|
||||||
|
|
||||||
|
jQuery permet de facilement manipuler le DOM et faire des requêtes en AJAX,
|
||||||
|
mais est moins pratique à utiliser pour créer des applications réactives.
|
||||||
|
C'est pour cette raison que Vue a été intégré au projet.
|
||||||
|
|
||||||
|
Vue est une librairie Javascript qui se concentre sur le rendu déclaratif et la composition des composants.
|
||||||
|
C'est une technologie très utilisée pour la création d'applications web monopages (ce que le site n'est pas)
|
||||||
|
mais son architecture progressivement adoptable permet aisément d'adapter son
|
||||||
|
comportement à une application multipage comme le site AE.
|
||||||
|
|
||||||
|
A ce jour, il est utilisé pour l'interface des barmen, dans l'application des comptoirs.
|
||||||
|
|
||||||
|
AlpineJS
|
||||||
|
~~~~~~~~
|
||||||
|
|
||||||
|
`Site officiel <https://alpinejs.dev/>`__
|
||||||
|
|
||||||
|
Dans une démarche similaire à celle de l'introduction de Vue, Alpine a aussi fait son
|
||||||
|
apparition au sein des dépendances Javascript du site.
|
||||||
|
La librairie est décrite par ses créateurs comme :
|
||||||
|
"un outil robuste et minimal pour composer un comportement directement dans vos balises".
|
||||||
|
|
||||||
|
Alpine permet d'accomplir la plupart du temps le même résultat qu'un usage des fonctionnalités
|
||||||
|
de base de Vue, mais est beaucoup plus léger, un peu plus facile à prendre en main
|
||||||
|
et ne s'embarasse pas d'un DOM virtuel.
|
||||||
|
De par son architecture, il extrêmement bien adapté pour un usage dans un site multipage.
|
||||||
|
C'est une technologie simple et puissante qui se veut comme le jQuery du web moderne.
|
||||||
|
|
||||||
Sass
|
Sass
|
||||||
~~~~
|
~~~~
|
||||||
@ -102,7 +144,7 @@ Fontawesome regroupe tout un ensemble d'icônes libres de droits utilisables fac
|
|||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
C'est une dépendance capricieuse qu'il évolue très vite et qu'il faut très souvent mettre à jour.
|
C'est une dépendance capricieuse qui évolue très vite et qu'il faut très souvent mettre à jour.
|
||||||
|
|
||||||
.. warning::
|
.. warning::
|
||||||
|
|
||||||
@ -140,17 +182,28 @@ Git
|
|||||||
|
|
||||||
`Site officiel <https://git-scm.com/>`__
|
`Site officiel <https://git-scm.com/>`__
|
||||||
|
|
||||||
Git est un logiciel de gestion de versions écrit par Linus Torsvald pour les besoins du noyau linux en 2005. C'est ce logiciel qui remplace svn anciennement utilisé pour gérer les sources du projet (rappelez vous, l'ancien site date d'avant 2005). Git est plus complexe à utiliser mais est bien plus puissant, permet de gérer plusieurs version en parallèle et génère des codebases vraiment plus légères puisque seules les modifications sont enregistrées (contrairement à svn qui garde une copie de la codebase par version).
|
Git est un logiciel de gestion de versions écrit par Linus Torvalds pour les besoins du noyau linux en 2005. C'est ce logiciel qui remplace svn anciennement utilisé pour gérer les sources du projet (rappelez vous, l'ancien site date d'avant 2005). Git est plus complexe à utiliser mais est bien plus puissant, permet de gérer plusieurs version en parallèle et génère des codebases vraiment plus légères puisque seules les modifications sont enregistrées (contrairement à svn qui garde une copie de la codebase par version).
|
||||||
|
|
||||||
GitLab
|
Git s'étant imposé comme le principal outil de gestion de versions,
|
||||||
|
sa communauté est très grande et sa documentation très fournie.
|
||||||
|
Il est également aisé de trouver des outils avec une interface graphique,
|
||||||
|
qui simplifient grandement son usage.
|
||||||
|
|
||||||
|
GitHub
|
||||||
~~~~~~
|
~~~~~~
|
||||||
|
|
||||||
| `Site officiel <https://about.gitlab.com/>`__
|
| `Site officiel <https://github.com>`__
|
||||||
| `Instance de l'AE <https://github.com/ae-utbm/>`__
|
| `Page github du Pôle Informatique de l'AE <https://github.com/ae-utbm/>`__
|
||||||
|
|
||||||
GitLab est une alternative libre à GitHub. C'est une plate-forme avec interface web permettant de déposer du code géré avec Git offrant également de l'intégration continue et du déploiement automatique.
|
Github est un service web d'hébergement et de gestion de développement de logiciel.
|
||||||
|
C'est une plate-forme avec interface web permettant de déposer du code géré avec Git
|
||||||
|
offrant également de l'intégration continue et du déploiement automatique.
|
||||||
|
C'est au travers de cette plate-forme que le Sith de l'AE est géré.
|
||||||
|
|
||||||
C'est au travers de cette plate-forme que le Sith de l'AE est géré, sur une instance hébergée directement sur nos serveurs.
|
Depuis le 1er Octobre 2022, GitHub remplace GitLab dans un soucis de facilité d'entretien,
|
||||||
|
les machines sur lesquelles tournent le site étant de plus en plus vielles, il devenait très
|
||||||
|
difficile d'effectuer les mise à jours de sécurité du GitLab sans avoir de soucis matériel
|
||||||
|
pour l'hébergement et la gestion des projets informatiques de l'AE.
|
||||||
|
|
||||||
Sentry
|
Sentry
|
||||||
~~~~~~
|
~~~~~~
|
||||||
@ -166,7 +219,9 @@ Poetry
|
|||||||
`Utiliser Poetry <https://python-poetry.org/docs/basic-usage/>`__
|
`Utiliser Poetry <https://python-poetry.org/docs/basic-usage/>`__
|
||||||
|
|
||||||
Poetry est un utilitaire qui permet de créer et gérer des environements Python de manière simple et intuitive. Il permet également de gérer et mettre à jour le fichier de dépendances.
|
Poetry est un utilitaire qui permet de créer et gérer des environements Python de manière simple et intuitive. Il permet également de gérer et mettre à jour le fichier de dépendances.
|
||||||
L'avantage d'utiliser poetry (et les environnements virtuels en général) est de pouvoir gérer plusieurs projets différents en parallèles puisqu'il permet d'avoir sur sa machine plusieurs environnements différents et donc plusieurs versions d'une même dépendance dans plusieurs projets différent sans impacter le système sur lequel le tout est installé.
|
L'avantage d'utiliser poetry (et les environnements virtuels en général) est de pouvoir gérer plusieurs projets différents en parallèle puisqu'il permet d'avoir sur sa machine plusieurs environnements différents et donc plusieurs versions d'une même dépendance dans plusieurs projets différent sans impacter le système sur lequel le tout est installé.
|
||||||
|
|
||||||
|
Les dépendances utilisées par poetry sont déclarées dans le fichier `pyproject.toml`, situé à la racine du projet.
|
||||||
|
|
||||||
Black
|
Black
|
||||||
~~~~~
|
~~~~~
|
||||||
@ -175,4 +230,4 @@ Black
|
|||||||
|
|
||||||
Pour faciliter la lecture du code, il est toujours appréciable d'avoir une norme d'écriture cohérente. C'est généralement à l'étape de relecture des modifications par les autres contributeurs que sont repérées ces fautes de normes qui se doivent d'être corrigées pour le bien commun.
|
Pour faciliter la lecture du code, il est toujours appréciable d'avoir une norme d'écriture cohérente. C'est généralement à l'étape de relecture des modifications par les autres contributeurs que sont repérées ces fautes de normes qui se doivent d'être corrigées pour le bien commun.
|
||||||
|
|
||||||
Imposer une norme est très fastidieux, que ce soit pour ceux qui relisent ou pour ceux qui écrivent. C'est pour cela que nous utilisons black qui est un formateur automatique de code. Une fois l'outil lancé, il parcours la codebase pour y repérer les fautes de norme et les corrige automatiquement sans que l'utilisateur ai à s'en soucier. Bien installé, il peut effectuer ce travail à chaque sauvegarde d'un fichier dans son éditeur, ce qui est très agréable pour travailler.
|
Imposer une norme est très fastidieux, que ce soit pour ceux qui relisent ou pour ceux qui écrivent. C'est pour cela que nous utilisons black qui est un formateur automatique de code. Une fois l'outil lancé, il parcours la codebase pour y repérer les fautes de norme et les corrige automatiquement sans que l'utilisateur n'ait à s'en soucier. Bien installé, il peut effectuer ce travail à chaque sauvegarde d'un fichier dans son éditeur, ce qui est très agréable pour travailler.
|
@ -3,11 +3,11 @@ Le versioning
|
|||||||
|
|
||||||
Dans le monde du développement, nous faisons face à un problème relativement étrange pour un domaine aussi avancé : on est brouillon.
|
Dans le monde du développement, nous faisons face à un problème relativement étrange pour un domaine aussi avancé : on est brouillon.
|
||||||
|
|
||||||
On teste, on envoie, ça marche pas, on reteste, c'est ok. Par contre, on a oublie plein d'exceptions. Et on refactor. Ça marche mieux mais c'est moins rapide, etc, etc.
|
On teste, on envoie, ça marche pas, on reteste, c'est ok. Par contre, on a oublié plein d'exceptions. Et on refactor. Ça marche mieux mais c'est moins rapide, etc.
|
||||||
|
|
||||||
Et derrière tout ça, on fait des trucs qui marchent puis on se retrouve dans la mouise parce qu'on a effacé un morceau de code qui nous aurait servi plus tard.
|
Et derrière tout ça, on fait des trucs qui marchent puis on se retrouve dans la mouise parce qu'on a effacé un morceau de code qui nous aurait servi plus tard.
|
||||||
|
|
||||||
Pour palier à ce problème, le programmeur a créé un principe révolutionnaire (ouais... à mon avis, on s'est inspiré d'autres trucs, mais on va dire que c'est nous les créateurs) : le Versioning (*Apparition inexpliquée*).
|
Pour pallier ce problème, le programmeur a créé un principe révolutionnaire (ouais... à mon avis, on s'est inspiré d'autres trucs, mais on va dire que c'est nous les créateurs) : le Versioning (*Apparition inexpliquée*).
|
||||||
|
|
||||||
D'après projet-isika (c'est pas wikipedia ouais, ils étaient pas clairs eux), le versioning (ou versionnage en français mais c'est quand même vachement dégueu comme mot) consiste à travailler directement sur le code source d'un projet, en gardant toutes les versions précédentes. Les outils du versioning aident les développeurs à travailler parallèlement sur différentes parties du projet et à revenir facilement aux étapes précédentes de leur travail en cas de besoin. L’utilisation d’un logiciel de versioning est devenue quasi-indispensable pour tout développeur, même s’il travaille seul.
|
D'après projet-isika (c'est pas wikipedia ouais, ils étaient pas clairs eux), le versioning (ou versionnage en français mais c'est quand même vachement dégueu comme mot) consiste à travailler directement sur le code source d'un projet, en gardant toutes les versions précédentes. Les outils du versioning aident les développeurs à travailler parallèlement sur différentes parties du projet et à revenir facilement aux étapes précédentes de leur travail en cas de besoin. L’utilisation d’un logiciel de versioning est devenue quasi-indispensable pour tout développeur, même s’il travaille seul.
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ Il arrive régulièrement que le type de cotisation proposé varie en prix et en
|
|||||||
Comprendre la configuration
|
Comprendre la configuration
|
||||||
---------------------------
|
---------------------------
|
||||||
|
|
||||||
Pour modifier les cotisations disponnibles, tout se gère dans la configuration avec la variable *SITH_SUBSCRIPTIONS*. Dans cet exemple, nous allons ajouter une nouvelle cotisation d'un mois.
|
Pour modifier les cotisations disponibles, tout se gère dans la configuration avec la variable *SITH_SUBSCRIPTIONS*. Dans cet exemple, nous allons ajouter une nouvelle cotisation d'un mois.
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
|
@ -3,19 +3,37 @@ Configurer son environnement de développement
|
|||||||
|
|
||||||
Le projet n'est en aucun cas lié à un quelconque environnement de développement. Il est possible pour chacun de travailler avec les outils dont il a envie et d'utiliser l'éditeur de code avec lequel il est le plus à l'aise.
|
Le projet n'est en aucun cas lié à un quelconque environnement de développement. Il est possible pour chacun de travailler avec les outils dont il a envie et d'utiliser l'éditeur de code avec lequel il est le plus à l'aise.
|
||||||
|
|
||||||
Pour donner une idée, Skia a écrit une énorme partie de projet avec l'éditeur *vim* sur du GNU/Linux alors que Sli a utilisé *Sublime Text* sur MacOS.
|
Pour donner une idée, Skia a écrit une énorme partie de projet avec l'éditeur *Vim* sur du GNU/Linux
|
||||||
|
alors que Sli a utilisé *Sublime Text* sur MacOS et que Maréchal travaille avec PyCharm
|
||||||
|
sur Windows muni de WSL.
|
||||||
|
|
||||||
Configurer Black pour son éditeur
|
Configurer Black pour son éditeur
|
||||||
---------------------------------
|
---------------------------------
|
||||||
|
|
||||||
Tous les détails concernant l'installation de black sont ici : https://black.readthedocs.io/en/stable/editor_integration.html
|
.. note::
|
||||||
|
|
||||||
Néanmoins, nous tenterons de vous faire ici un résumé pour deux éditeurs de textes populaires que sont VsCode et Sublime Text.
|
Black est inclus dans les dépendances du projet.
|
||||||
|
Si vous avez réussi à terminer l'installation, vous n'avez donc pas de configuration
|
||||||
|
supplémentaire à effectuer.
|
||||||
|
|
||||||
.. sourcecode:: bash
|
Pour utiliser Black, placez-vous à la racine du projet et lancez la commande suivante :
|
||||||
|
|
||||||
# Installation de black
|
.. code-block::
|
||||||
pip install black
|
|
||||||
|
black .
|
||||||
|
|
||||||
|
Black va alors faire son travail sur l'ensemble du projet puis vous dire quels documents
|
||||||
|
ont été reformatés.
|
||||||
|
|
||||||
|
Appeler Black en ligne de commandes avant de pousser votre code sur Github
|
||||||
|
est une technique qui marche très bien.
|
||||||
|
Cependant, vous risquez de souvent l'oublier.
|
||||||
|
Or, lorsque le code est mal formaté, la pipeline bloque les PR sur les branches protégées.
|
||||||
|
|
||||||
|
Pour éviter de vous faire régulièrement blacked, vous pouvez configurer
|
||||||
|
votre éditeur pour que Black fasse son travail automatiquement à chaque édition d'un fichier.
|
||||||
|
Nous tenterons de vous faire ici un résumé pour deux éditeurs de textes populaires
|
||||||
|
que sont VsCode et Sublime Text.
|
||||||
|
|
||||||
VsCode
|
VsCode
|
||||||
~~~~~~
|
~~~~~~
|
||||||
|
@ -32,13 +32,13 @@ Enfin, on vas inclure les URLs de cette application dans le projet sous le préf
|
|||||||
# sith/urls.py
|
# sith/urls.py
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
...
|
...
|
||||||
url(r"^hello/", include("hello.urls", namespace="hello", app_name="hello")),
|
path("hello/", include("hello.urls", namespace="hello", app_name="hello")),
|
||||||
]
|
]
|
||||||
|
|
||||||
Un Hello World simple
|
Un Hello World simple
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
Dans un premier temps, nous allons créer une vue qui vas charger un template en utilisant le système de vues basées sur les classes de Django.
|
Dans un premier temps, nous allons créer une vue qui va charger un template en utilisant le système de vues basé sur les classes de Django.
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
@ -64,25 +64,25 @@ On vas ensuite créer le template.
|
|||||||
{# Il s'agit du titre qui sera affiché dans l'onglet du navigateur #}
|
{# Il s'agit du titre qui sera affiché dans l'onglet du navigateur #}
|
||||||
{% block title %}
|
{% block title %}
|
||||||
Hello World
|
Hello World
|
||||||
{% endblock title %}
|
{% endblock %}
|
||||||
|
|
||||||
{# On remplis le contenu de la page #}
|
{# On remplis le contenu de la page #}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p>Hello World !</p>
|
<p>Hello World !</p>
|
||||||
{% endblock content %}
|
{% endblock %}
|
||||||
|
|
||||||
Enfin, on crée l'URL. On veut pouvoir appeler la page depuis https://localhost:8000/hello, le préfixe indiqué précédemment suffit donc.
|
Enfin, on crée l'URL. On veut pouvoir appeler la page depuis https://localhost:8000/hello, le préfixe indiqué précédemment suffit donc.
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
# hello/urls.py
|
# hello/urls.py
|
||||||
from django.conf.urls import url
|
from django.urls import path
|
||||||
from hello.views import HelloView
|
from hello.views import HelloView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Le préfixe étant retiré lors du passage du routeur d'URL
|
# Le préfixe étant retiré lors du passage du routeur d'URL
|
||||||
# dans le fichier d'URL racine du projet, l'URL à matcher ici est donc vide
|
# dans le fichier d'URL racine du projet, l'URL à matcher ici est donc vide
|
||||||
url(r"^$", HelloView.as_view(), name="hello"),
|
path("", HelloView.as_view(), name="hello"),
|
||||||
]
|
]
|
||||||
|
|
||||||
Et voilà, c'est fini, il ne reste plus qu'à lancer le serveur et à se rendre sur la page.
|
Et voilà, c'est fini, il ne reste plus qu'à lancer le serveur et à se rendre sur la page.
|
||||||
@ -95,13 +95,12 @@ Dans cette partie, on cherche à détecter les numéros passés dans l'URL pour
|
|||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
# hello/urls.py
|
# hello/urls.py
|
||||||
from django.conf.urls import url
|
from django.urls import path
|
||||||
from hello.views import HelloView
|
from hello.views import HelloView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r"^$", HelloView.as_view(), name="hello"),
|
path("", HelloView.as_view(), name="hello"),
|
||||||
# On utilise un regex pour matcher un numéro
|
path("<int:hello_id>", HelloView.as_view(), name="hello"),
|
||||||
url(r"^(?P<hello_id>[0-9]+)$", HelloView.as_view(), name="hello"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
Cette deuxième URL vas donc appeler la classe crée tout à l'heure en lui passant une variable *hello_id* dans ses *kwargs*, nous allons la récupérer et la passer dans le contexte du template en allant modifier la vue.
|
Cette deuxième URL vas donc appeler la classe crée tout à l'heure en lui passant une variable *hello_id* dans ses *kwargs*, nous allons la récupérer et la passer dans le contexte du template en allant modifier la vue.
|
||||||
@ -162,7 +161,7 @@ Enfin, on modifie le template en rajoutant une petite condition sur la présence
|
|||||||
À l'assaut des modèles
|
À l'assaut des modèles
|
||||||
----------------------
|
----------------------
|
||||||
|
|
||||||
Pour cette dernière partie, nous allons ajouter une entrée dans la base de donnée et l'afficher dans un template. Nous allons ainsi créer un modèle nommé *Article* qui contiendra une entrée de texte pour le titre et une autre pour le contenu.
|
Pour cette dernière partie, nous allons ajouter une entrée dans la base de données et l'afficher dans un template. Nous allons ainsi créer un modèle nommé *Article* qui contiendra une entrée de texte pour le titre et une autre pour le contenu.
|
||||||
|
|
||||||
Commençons par le modèle en lui même.
|
Commençons par le modèle en lui même.
|
||||||
|
|
||||||
@ -203,7 +202,7 @@ On n'oublie pas l'URL.
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
...
|
...
|
||||||
url(r"^articles$", ArticlesListView.as_view(), name="articles_list")
|
path("articles/", ArticlesListView.as_view(), name="articles_list")
|
||||||
]
|
]
|
||||||
|
|
||||||
Et enfin le template.
|
Et enfin le template.
|
||||||
@ -227,7 +226,7 @@ Et enfin le template.
|
|||||||
|
|
||||||
Maintenant que toute la logique de récupération et d'affichage est terminée, la page est accessible à l'adresse https://localhost:8000/hello/articles.
|
Maintenant que toute la logique de récupération et d'affichage est terminée, la page est accessible à l'adresse https://localhost:8000/hello/articles.
|
||||||
|
|
||||||
Mais, j'ai une erreur ! Il se passe quoi ?! Et bien c'est simple, nous avons crée le modèle mais il n'existe pas dans la base de données. Il est dans un premier temps important de créer un fichier de migrations qui contiens des instructions pour la génération de celle-ci. Ce sont les fichiers qui sont enregistrés dans le dossier migration. Pour les générer à partir des classes de modèles qu'on viens de manipuler il suffit d'une seule commande.
|
Mais, j'ai une erreur ! Il se passe quoi ?! Et bien c'est simple, nous avons créé le modèle mais il n'existe pas dans la base de données. Il est dans un premier temps important de créer un fichier de migrations qui contient des instructions pour la génération de celles-ci. Ce sont les fichiers qui sont enregistrés dans le dossier migration. Pour les générer à partir des classes de modèles qu'on vient de manipuler il suffit d'une seule commande.
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
@ -245,7 +244,7 @@ J'ai toujours une erreur ! Mais oui, c'est pas fini, faut pas aller trop vite. M
|
|||||||
|
|
||||||
./manage.py migrate
|
./manage.py migrate
|
||||||
|
|
||||||
Et voilà, là il n'y a plus d'erreur. Tout fonctionne et on a une superbe page vide puisque aucun contenu pour cette table n'est dans la base. Nous allons en rajouter. Pour cela nous allons utiliser le fichier *core/management/commands/populate.py* qui contiens la commande qui initialise les données de la base de données de test. C'est un fichier très important qu'on viendra à modifier assez souvent. Nous allons y ajouter quelques articles.
|
Et voilà, là il n'y a plus d'erreur. Tout fonctionne et on a une superbe page vide puisque aucun contenu pour cette table n'est dans la base. Nous allons en rajouter. Pour cela nous allons utiliser le fichier *core/management/commands/populate.py* qui contient la commande qui initialise les données de la base de données de test. C'est un fichier très important qu'on viendra à modifier assez souvent. Nous allons y ajouter quelques articles.
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
@ -262,14 +261,15 @@ Et voilà, là il n'y a plus d'erreur. Tout fonctionne et on a une superbe page
|
|||||||
|
|
||||||
...
|
...
|
||||||
|
|
||||||
|
# les deux syntaxes ci-dessous sont correctes et donnent le même résultat
|
||||||
Article(title="First hello", content="Bonjour tout le monde").save()
|
Article(title="First hello", content="Bonjour tout le monde").save()
|
||||||
Article(title="Tutorial", content="C'était un super tutoriel").save()
|
Article.objects.create(title="Tutorial", content="C'était un super tutoriel")
|
||||||
|
|
||||||
|
|
||||||
On regénère enfin les données de test en lançant la commande que l'on viens de modifier.
|
On regénère enfin les données de test en lançant la commande que l'on vient de modifier.
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
./manage.py setup
|
./manage.py setup
|
||||||
|
|
||||||
On reviens sur https://localhost:8000/hello/articles et cette fois-ci nos deux articles apparaissent correctement.
|
On revient sur https://localhost:8000/hello/articles et cette fois-ci nos deux articles apparaissent correctement.
|
@ -7,7 +7,6 @@ Dépendances du système
|
|||||||
Certaines dépendances sont nécessaires niveau système :
|
Certaines dépendances sont nécessaires niveau système :
|
||||||
|
|
||||||
* poetry
|
* poetry
|
||||||
* libmysqlclient
|
|
||||||
* libssl
|
* libssl
|
||||||
* libjpeg
|
* libjpeg
|
||||||
* libxapian-dev
|
* libxapian-dev
|
||||||
@ -15,18 +14,80 @@ Certaines dépendances sont nécessaires niveau système :
|
|||||||
* python
|
* python
|
||||||
* gettext
|
* gettext
|
||||||
* graphviz
|
* graphviz
|
||||||
* mysql-client (pour migrer de l'ancien site)
|
|
||||||
|
Sur Windows
|
||||||
|
~~~~~~~~~~~
|
||||||
|
|
||||||
|
Chers utilisateurs de Windows, quel que soit votre amour de Windows,
|
||||||
|
de Bill Gates et des bloatwares, je suis désolé
|
||||||
|
de vous annoncer que, certaines dépendances étant uniquement disponibles sur des sytèmes UNIX,
|
||||||
|
il n'est pas possible développer le site sur Windows.
|
||||||
|
|
||||||
|
Heureusement, il existe une alternative qui ne requiert pas de désinstaller votre
|
||||||
|
OS ni de mettre un dual boot sur votre ordinateur : :code:`WSL`.
|
||||||
|
|
||||||
|
- **Prérequis:** vous devez être sur Windows 10 version 2004 ou ultérieure (build 19041 & versions ultérieures) ou Windows 11.
|
||||||
|
- **Plus d'info:** `docs.microsoft.com <https://docs.microsoft.com/fr-fr/windows/wsl/install>`_
|
||||||
|
|
||||||
|
.. sourcecode:: bash
|
||||||
|
|
||||||
|
# dans un shell Windows
|
||||||
|
wsl --install
|
||||||
|
|
||||||
|
# afficher la liste des distribution disponible avec WSL
|
||||||
|
wsl -l -o
|
||||||
|
|
||||||
|
# installer WSL avec une distro (ubuntu conseillé)
|
||||||
|
wsl --install -d <nom_distro>
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
Si vous rencontrez le code d'erreur ``0x80370102``, regardez les réponses de ce `post <https://askubuntu.com/questions/1264102/wsl-2-wont-run-ubuntu-error-0x80370102>`_.
|
||||||
|
|
||||||
|
Une fois :code:`WSL` installé, mettez à jour votre distro & installez les dépendances **(voir la partie installation sous Ubuntu)**.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
Comme `git` ne fonctionne pas de la même manière entre Windows & Unix, il est nécessaire de cloner le repository depuis Windows.
|
||||||
|
(cf: `stackoverflow.com <https://stackoverflow.com/questions/62245016/how-to-git-clone-in-wsl>`_)
|
||||||
|
|
||||||
|
Pour accéder au contenu d'un répertoire externe à :code:`WSL`, il suffit simplement d'utiliser la commande suivante:
|
||||||
|
|
||||||
|
.. sourcecode:: bash
|
||||||
|
|
||||||
|
# oui c'est beau, simple et efficace
|
||||||
|
cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
||||||
|
|
||||||
|
Une fois l'installation des dépendances terminée (juste en dessous), il vous suffira, pour commencer à dev, d'ouvrir votre plus bel IDE et d'avoir 2 consoles:
|
||||||
|
1 console :code:`WSL` pour lancer le projet & 1 console pour utiliser :code:`git`
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
A ce stade, si vous avez réussi votre installation de :code:`WSL` ou bien qu'il
|
||||||
|
était déjà installé, vous pouvez effectuer la mise en place du projet en suivant
|
||||||
|
les instructions pour Ubuntu.
|
||||||
|
|
||||||
|
|
||||||
Sur Ubuntu
|
Sur Ubuntu
|
||||||
~~~~~~~~~~
|
~~~~~~~~~~
|
||||||
|
|
||||||
.. sourcecode:: bash
|
.. sourcecode:: bash
|
||||||
|
|
||||||
sudo apt install libssl-dev libjpeg-dev zlib1g-dev python-dev libffi-dev python-dev libgraphviz-dev pkg-config libxapian-dev gettext git
|
# Sait-on jamais
|
||||||
|
sudo apt update
|
||||||
|
|
||||||
|
sudo apt install python-is-python3 # Permet d'utiliser python au lieu de python3, c'est optionel
|
||||||
|
|
||||||
|
sudo apt install build-essentials libssl-dev libjpeg-dev zlib1g-dev python-dev \
|
||||||
|
libffi-dev python-dev-is-python3 libgraphviz-dev pkg-config libxapian-dev \
|
||||||
|
gettext git
|
||||||
|
|
||||||
curl -sSL https://install.python-poetry.org | python -
|
curl -sSL https://install.python-poetry.org | python -
|
||||||
|
|
||||||
# To include mysql for importing old bdd
|
.. note::
|
||||||
sudo apt install libmysqlclient-dev
|
|
||||||
|
Si vous avez réussi à exécuter les instructions ci-dessus sans trop de problèmes,
|
||||||
|
vous pouvez passer à la partie :ref:`Finalise installation`
|
||||||
|
|
||||||
Sur MacOS
|
Sur MacOS
|
||||||
~~~~~~~~~
|
~~~~~~~~~
|
||||||
@ -51,61 +112,21 @@ Pour installer les dépendances, il est fortement recommandé d'installer le ges
|
|||||||
|
|
||||||
Si vous rencontrez des erreurs lors de votre configuration, n'hésitez pas à vérifier l'état de votre installation homebrew avec :code:`brew doctor`
|
Si vous rencontrez des erreurs lors de votre configuration, n'hésitez pas à vérifier l'état de votre installation homebrew avec :code:`brew doctor`
|
||||||
|
|
||||||
Sur Windows avec :code:`WSL`
|
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
Comme certaines dépendances sont uniquement disponible dans un environnement Unix, il est obligatoire de passer par :code:`WSL` pour installer le projet.
|
Si vous avez réussi à exécuter les instructions ci-dessus sans trop de problèmes,
|
||||||
|
vous pouvez passer à la partie :ref:`Finalise installation`
|
||||||
|
|
||||||
- **Prérequis:** vous devez exécuter Windows 10 versions 2004 et ultérieures (build 19041 & versions ultérieures) ou Windows 11.
|
.. _Finalise installation:
|
||||||
- **Plus d'info:** `docs.microsoft.com <https://docs.microsoft.com/fr-fr/windows/wsl/install>`_
|
|
||||||
|
Finaliser l'installation
|
||||||
|
------------------------
|
||||||
|
|
||||||
.. sourcecode:: bash
|
.. sourcecode:: bash
|
||||||
|
|
||||||
# dans un shell Windows
|
|
||||||
wsl --install
|
|
||||||
|
|
||||||
# afficher la liste des distribution disponible avec WSL
|
|
||||||
wsl -l -o
|
|
||||||
|
|
||||||
# installer WSL avec une distro
|
|
||||||
wsl --install -d <nom_distro>
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
Si vous rencontrez le code d'erreur ``0x80370102``, regardez les réponses de ce `post <https://askubuntu.com/questions/1264102/wsl-2-wont-run-ubuntu-error-0x80370102>`_.
|
|
||||||
|
|
||||||
Une fois :code:`WSL` installé, mettez à jour votre distro & installez les dépendances **(voir la partie installation sous Ubuntu)**.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
Comme `git` ne fonctionne pas de la même manière entre Windows & Unix, il est nécessaire de cloner le repository depuis Windows.
|
|
||||||
(cf: `stackoverflow.com <https://stackoverflow.com/questions/62245016/how-to-git-clone-in-wsl>`_)
|
|
||||||
|
|
||||||
Pour accéder au contenu d'un répertoire externe à :code:`WSL`, il suffit simplement d'utiliser la commande suivante:
|
|
||||||
|
|
||||||
.. sourcecode:: bash
|
|
||||||
|
|
||||||
# oui c'est beau, simple et efficace
|
|
||||||
cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
Une fois l'installation des dépendances terminée (juste en dessous), il vous suffira, pour commencer à dev, d'ouvrir votre plus bel IDE et d'avoir 2 consoles:
|
|
||||||
1 console :code:`WSL` pour lancer le projet & 1 console pour utiliser :code:`git`
|
|
||||||
|
|
||||||
Installer le projet
|
|
||||||
-----------------------------------
|
|
||||||
|
|
||||||
.. sourcecode:: bash
|
|
||||||
|
|
||||||
# Sait-on jamais
|
|
||||||
sudo apt update
|
|
||||||
|
|
||||||
# Les commandes git doivent se faire depuis le terminal de Windows si on utilise WSL !
|
# Les commandes git doivent se faire depuis le terminal de Windows si on utilise WSL !
|
||||||
git clone https://github.com/ae-utbm/sith3.git
|
git clone https://github.com/ae-utbm/sith3.git
|
||||||
cd Sith
|
cd sith3
|
||||||
|
|
||||||
# Création de l'environnement et installation des dépendances
|
# Création de l'environnement et installation des dépendances
|
||||||
poetry install
|
poetry install
|
||||||
@ -113,7 +134,7 @@ Installer le projet
|
|||||||
# Activation de l'environnement virtuel
|
# Activation de l'environnement virtuel
|
||||||
poetry shell
|
poetry shell
|
||||||
|
|
||||||
# Prépare la base de donnée
|
# Prépare la base de données
|
||||||
python manage.py setup
|
python manage.py setup
|
||||||
|
|
||||||
# Installe les traductions
|
# Installe les traductions
|
||||||
@ -144,15 +165,21 @@ Il faut toujours avoir préalablement activé l'environnement virtuel comme fait
|
|||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
Le serveur est alors accessible à l'adresse http://localhost:8000.
|
Le serveur est alors accessible à l'adresse http://localhost:8000 ou bien http://127.0.0.1:8000/.
|
||||||
|
|
||||||
Générer la documentation
|
Générer la documentation
|
||||||
------------------------
|
------------------------
|
||||||
|
|
||||||
La documentation est automatiquement mise en ligne sur readthedocs à chaque envoi de code sur GitLab.
|
La documentation est automatiquement mise en ligne sur readthedocs à chaque envoi de code sur GitHub.
|
||||||
Pour l'utiliser en local ou globalement pour la modifier, il existe une commande du site qui génère la documentation et lance un serveur la rendant accessible à l'adresse http://localhost:8080.
|
Pour l'utiliser en local ou globalement pour la modifier, il existe une commande du site qui génère la documentation et lance un serveur la rendant accessible à l'adresse http://localhost:8080.
|
||||||
Cette commande génère la documentation à chacune de ses modifications, inutile de relancer le serveur à chaque fois.
|
Cette commande génère la documentation à chacune de ses modifications, inutile de relancer le serveur à chaque fois.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
Les dépendances pour la documentation sont optionnelles.
|
||||||
|
Avant de commencer à travailler sur la doc, il faut donc les installer
|
||||||
|
avec la commande :code:`poetry install -E docs`
|
||||||
|
|
||||||
.. sourcecode:: bash
|
.. sourcecode:: bash
|
||||||
|
|
||||||
python manage.py documentation
|
python manage.py documentation
|
||||||
|
@ -35,39 +35,40 @@ Le découpage en applications
|
|||||||
| **api/**
|
| **api/**
|
||||||
| Application où mettre les endpoints publiques d'API.
|
| Application où mettre les endpoints publiques d'API.
|
||||||
| **club/**
|
| **club/**
|
||||||
| Contiens les modèles liés aux clubs associatifs et ajoute leur gestion.
|
| Contient les modèles liés aux clubs et assos et ajoute leur gestion.
|
||||||
| **com/**
|
| **com/**
|
||||||
| Fournis des outils de communications aux clubs (weekmail, affiches…).
|
| Fournis des outils de communications aux clubs (weekmail, affiches…).
|
||||||
| **core/**
|
| **core/**
|
||||||
| Application la plus importante. Contiens les principales surcouches
|
| Application la plus importante. Contient les principales surcouches
|
||||||
| liées au projet comme la gestion des droits et les templates de base.
|
| liées au projet comme la gestion des droits et les templates de base.
|
||||||
| **counter/**
|
| **counter/**
|
||||||
| Ajoute des comptoirs de vente pour les clubs et gère les ventes sur les lieux de vie.
|
| Ajoute des comptoirs de vente pour les clubs et gère les ventes sur les lieux de vie.
|
||||||
| **data/**
|
| **data/**
|
||||||
| Contiens les fichiers statiques ajoutées par les utilisateurs.
|
| Contient les fichiers statiques ajoutés par les utilisateurs.
|
||||||
| N'est pas suivit par Git.
|
| N'est pas suivi par Git.
|
||||||
| **doc/**
|
| **doc/**
|
||||||
| Contiens la documentation du projet.
|
| Contient la documentation du projet.
|
||||||
| **eboutic/**
|
| **eboutic/**
|
||||||
| Ajoute le comptoir de vente en ligne. Permet d'acheter en carte bancaire.
|
| Ajoute le comptoir de vente en ligne. Permet d'acheter en carte bancaire.
|
||||||
| **election/**
|
| **election/**
|
||||||
| Ajoute un système d'élection permettant d'élire les représentants étudiants.
|
| Ajoute un système d'élection permettant d'élire les représentants étudiants.
|
||||||
| **forum/**
|
| **forum/**
|
||||||
| Ajoute un forum de discutions.
|
| Ajoute un forum de discussion.
|
||||||
| **launderette/**
|
| **launderette/**
|
||||||
| Permet la gestion des laveries.
|
| Permet la gestion des laveries.
|
||||||
| **locale/**
|
| **locale/**
|
||||||
| Contiens les fichiers de traduction.
|
| Contient les fichiers de traduction.
|
||||||
| **matmat/**
|
| **matmat/**
|
||||||
| Système de recherche de membres.
|
| Système de recherche de membres.
|
||||||
| **pedagogy/**
|
| **pedagogy/**
|
||||||
| Contiens le guide des UVs.
|
| Contient le guide des UVs.
|
||||||
| **rootplace/**
|
| **rootplace/**
|
||||||
| Ajoute des outils destinés aux administrateurs.
|
| Ajoute des outils destinés aux administrateurs.
|
||||||
| **static/**
|
| **static/**
|
||||||
| Contiens l'ensemble des fichiers statiques ajoutés par les développeurs.
|
| Contient l'ensemble des fichiers statiques ajoutés par les développeurs.
|
||||||
| Ce dossier est généré par le framework, il est surtout utile en production.
|
| Ce dossier est généré par le framework, il est surtout utile en production ;
|
||||||
| Ce dossier n'es pas suivit par Git.
|
| évitez d'y toucher pendant le développement.
|
||||||
|
| Ce dossier n'est pas suivi par Git.
|
||||||
| **stock/**
|
| **stock/**
|
||||||
| Système de gestion des stocks.
|
| Système de gestion des stocks.
|
||||||
| **subscription/**
|
| **subscription/**
|
||||||
@ -78,13 +79,13 @@ Le découpage en applications
|
|||||||
| Configure l'outil permettant de calculer la couverture des tests sur le projet.
|
| Configure l'outil permettant de calculer la couverture des tests sur le projet.
|
||||||
| **.gitignore**
|
| **.gitignore**
|
||||||
| Permet de définir quels fichiers sont suivis ou non par Git.
|
| Permet de définir quels fichiers sont suivis ou non par Git.
|
||||||
| **.gitlab-ci.yml**
|
| **.github/**
|
||||||
| Permet de configurer la pipeline automatique de GitLab.
|
| Contient les fichiers de configuration des actions github.
|
||||||
| **.readthedocs.yml**
|
| **.readthedocs.yml**
|
||||||
| Permet de configurer la génération de documentation sur Readthedocs.
|
| Permet de configurer la génération de documentation sur Readthedocs.
|
||||||
| **.db.sqlite3**
|
| **.db.sqlite3**
|
||||||
| Base de données de développement par défaut. Est automatiquement généré
|
| Base de données de développement par défaut. Est automatiquement généré
|
||||||
| lors de la configuration du projet en local. N'est pas suivis par Git.
|
| lors de la configuration du projet en local. N'est pas suivie par Git.
|
||||||
| **LICENSE**
|
| **LICENSE**
|
||||||
| Licence du projet.
|
| Licence du projet.
|
||||||
| **LICENSE.old**
|
| **LICENSE.old**
|
||||||
@ -93,10 +94,10 @@ Le découpage en applications
|
|||||||
| Permet de lancer les commandes liées au framework Django.
|
| Permet de lancer les commandes liées au framework Django.
|
||||||
| **migrate.py**
|
| **migrate.py**
|
||||||
| Contiens des scripts de migration à exécuter pour importer les données de l'ancien site.
|
| Contiens des scripts de migration à exécuter pour importer les données de l'ancien site.
|
||||||
| **README.rst**
|
| **README.md**
|
||||||
| Fichier de README. À lire pour avoir des informations sur le projet.
|
| Fichier de README. À lire pour avoir des informations sur le projet.
|
||||||
| **requirements.txt**
|
| **pyproject.toml**
|
||||||
| Contiens les dépendances Python du projet.
|
| Contient les dépendances Python du projet.
|
||||||
|
|
||||||
|
|
||||||
L'application principale
|
L'application principale
|
||||||
@ -107,16 +108,20 @@ L'application principale
|
|||||||
| Permet de définir le dossier comme un package Python.
|
| Permet de définir le dossier comme un package Python.
|
||||||
| Ce fichier est vide.
|
| Ce fichier est vide.
|
||||||
| **settings.py**
|
| **settings.py**
|
||||||
| Contiens les paramètres par défaut du projet.
|
| Contient les paramètres par défaut du projet.
|
||||||
| Ce fichier est versionné et fait partie intégrant de celui-ci.
|
| Ce fichier est versionné et fait partie intégrant de celui-ci.
|
||||||
| **settings_curtom.py**
|
| Notez que les informations sensibles qui se trouvent dans ce fichier
|
||||||
| Contiens les paramètres spécifiques à l'installation courante.
|
| ne sont pas celles utilisées en production.
|
||||||
| Ce fichier n'est pas versionné et surcharges les paramètres par défaut.
|
| Ce sont des paramètres factices préremplies pour faciliter la mise en place
|
||||||
|
| du projet qui sont surchargés en production par les vrais paramètres.
|
||||||
|
| **settings_custom.py**
|
||||||
|
| Contient les paramètres spécifiques à l'installation courante.
|
||||||
|
| Ce fichier n'est pas versionné et surcharge les paramètres par défaut.
|
||||||
| **urls.py**
|
| **urls.py**
|
||||||
| Contiens les routes d'URLs racines du projet.
|
| Contient les routes d'URLs racines du projet.
|
||||||
| On y inclus les autres fichiers d'URLs et leur namespace.
|
| On y inclut les autres fichiers d'URLs et leur namespace.
|
||||||
| **toolbar_debug.py**
|
| **toolbar_debug.py**
|
||||||
| Contiens la configuration de la barre de debug à gauche à destination
|
| Contient la configuration de la barre de debug à gauche à destination
|
||||||
| du site de développement.
|
| du site de développement.
|
||||||
| **et_keys/**
|
| **et_keys/**
|
||||||
| Contiens la clef publique du système de paiement E-Transactions.
|
| Contiens la clef publique du système de paiement E-Transactions.
|
||||||
@ -131,23 +136,22 @@ Le contenu d'une application
|
|||||||
| /app1
|
| /app1
|
||||||
| **__init__.py**
|
| **__init__.py**
|
||||||
| Permet de définir le dossier comme un package Python.
|
| Permet de définir le dossier comme un package Python.
|
||||||
| Ce fichier est généralement vide.
|
|
||||||
| **models.py**
|
| **models.py**
|
||||||
| C'est là que les modèles sont définis. Ces classes définissent
|
| C'est là que les modèles sont définis. Ces classes définissent
|
||||||
| les tables dans la base de donnée.
|
| les tables dans la base de données.
|
||||||
| **views.py**
|
| **views.py**
|
||||||
| C'est là où les vues sont définies.
|
| C'est là où les vues sont définies.
|
||||||
| **admin.py**
|
| **admin.py**
|
||||||
| C'est là que l'on déclare quels modèles doivent apparaître
|
| C'est là que l'on déclare quels modèles doivent apparaître
|
||||||
| dans l'interface du module d'administration de Django.
|
| dans l'interface du module d'administration de Django.
|
||||||
| **tests.py**
|
| **tests.py**
|
||||||
| Ce fichier contiens les tests fonctionnels, unitaires
|
| Ce fichier contient les tests fonctionnels, unitaires
|
||||||
| mais aussi d'intégrations qui sont lancés par la pipeline.
|
| et d'intégrations qui sont lancés par la pipeline.
|
||||||
| **urls.py**
|
| **urls.py**
|
||||||
| On y défini les URLs de l'application et on les lies aux vues.
|
| On y définit les URLs de l'application et on les lie aux vues.
|
||||||
| **migrations/**
|
| **migrations/**
|
||||||
| Ce dossier sert à stocker les fichiers de migration de la base
|
| Ce dossier sert à stocker les fichiers de migration de la base
|
||||||
| de données générées par la commande *makemigrations*.
|
| de données générés par la commande *makemigrations*.
|
||||||
| **templates/**
|
| **templates/**
|
||||||
| Ce dossier ci contiens généralement des sous dossiers et sert
|
| Ce dossier-ci contient généralement des sous-dossiers et sert
|
||||||
| à accueillir les templates. Les sous dossiers servent de namespace.
|
| à accueillir les templates. Les sous dossiers servent de namespace.
|
||||||
|
@ -21,11 +21,32 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
from ajax_select import make_ajax_form
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from eboutic.models import *
|
from eboutic.models import *
|
||||||
|
|
||||||
admin.site.register(Basket)
|
|
||||||
admin.site.register(Invoice)
|
@admin.register(Basket)
|
||||||
admin.site.register(BasketItem)
|
class BasketAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "date", "get_total")
|
||||||
|
form = make_ajax_form(Basket, {"user": "users"})
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BasketItem)
|
||||||
|
class BasketItemAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("basket", "product_name", "product_unit_price", "quantity")
|
||||||
|
search_fields = ("product_name",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Invoice)
|
||||||
|
class InvoiceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "date", "validated")
|
||||||
|
search_fields = ("user__username", "user__first_name", "user__last_name")
|
||||||
|
form = make_ajax_form(Invoice, {"user": "users"})
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(InvoiceItem)
|
||||||
|
class InvoiceItemAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("invoice", "product_name", "product_unit_price", "quantity")
|
||||||
|
search_fields = ("product_name",)
|
||||||
|
@ -26,6 +26,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
|
from urllib.parse import unquote
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from sentry_sdk import capture_message
|
from sentry_sdk import capture_message
|
||||||
@ -98,12 +99,16 @@ class BasketForm:
|
|||||||
- all the ids refer to products the user is allowed to buy
|
- all the ids refer to products the user is allowed to buy
|
||||||
- all the quantities are positive integers
|
- all the quantities are positive integers
|
||||||
"""
|
"""
|
||||||
basket = self.cookies.get("basket_items", None)
|
# replace escaped double quotes by single quotes, as the RegEx used to check the json
|
||||||
if basket is None or basket in ("[]", ""):
|
# does not support escaped double quotes
|
||||||
|
basket = unquote(self.cookies.get("basket_items", "")).replace('\\"', "'")
|
||||||
|
|
||||||
|
if basket in ("[]", ""):
|
||||||
self.error_messages.add(_("You have no basket."))
|
self.error_messages.add(_("You have no basket."))
|
||||||
return
|
return
|
||||||
|
|
||||||
# check that the json is not nested before parsing it to make sure
|
# check that the json is not nested before parsing it to make sure
|
||||||
# malicious user can't ddos the server with deeply nested json
|
# malicious user can't DDoS the server with deeply nested json
|
||||||
if not BasketForm.json_cookie_re.match(basket):
|
if not BasketForm.json_cookie_re.match(basket):
|
||||||
# As the validation of the cookie goes through a rather boring regex,
|
# As the validation of the cookie goes through a rather boring regex,
|
||||||
# we can regularly have to deal with subtle errors that we hadn't forecasted,
|
# we can regularly have to deal with subtle errors that we hadn't forecasted,
|
||||||
@ -114,14 +119,17 @@ class BasketForm:
|
|||||||
)
|
)
|
||||||
self.error_messages.add(_("The request was badly formatted."))
|
self.error_messages.add(_("The request was badly formatted."))
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
basket = json.loads(basket)
|
basket = json.loads(basket)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
self.error_messages.add(_("The basket cookie was badly formatted."))
|
self.error_messages.add(_("The basket cookie was badly formatted."))
|
||||||
return
|
return
|
||||||
|
|
||||||
if type(basket) is not list or len(basket) == 0:
|
if type(basket) is not list or len(basket) == 0:
|
||||||
self.error_messages.add(_("Your basket is empty."))
|
self.error_messages.add(_("Your basket is empty."))
|
||||||
return
|
return
|
||||||
|
|
||||||
for item in basket:
|
for item in basket:
|
||||||
expected_keys = {"id", "quantity", "name", "unit_price"}
|
expected_keys = {"id", "quantity", "name", "unit_price"}
|
||||||
if type(item) is not dict or set(item.keys()) != expected_keys:
|
if type(item) is not dict or set(item.keys()) != expected_keys:
|
||||||
@ -146,7 +154,7 @@ class BasketForm:
|
|||||||
continue
|
continue
|
||||||
if type(item["quantity"]) is not int or item["quantity"] < 0:
|
if type(item["quantity"]) is not int or item["quantity"] < 0:
|
||||||
self.error_messages.add(
|
self.error_messages.add(
|
||||||
_("You cannot buy %(nbr)d %(name)%s.")
|
_("You cannot buy %(nbr)d %(name)s.")
|
||||||
% {"nbr": item["quantity"], "name": item["name"]}
|
% {"nbr": item["quantity"], "name": item["name"]}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@ -174,7 +182,6 @@ class BasketForm:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def get_error_messages(self) -> typing.List[str]:
|
def get_error_messages(self) -> typing.List[str]:
|
||||||
# return [msg for msg in self.error_messages]
|
|
||||||
return list(self.error_messages)
|
return list(self.error_messages)
|
||||||
|
|
||||||
def get_cleaned_cookie(self) -> str:
|
def get_cleaned_cookie(self) -> str:
|
||||||
|
@ -21,9 +21,13 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
import hmac
|
||||||
|
import html
|
||||||
import typing
|
import typing
|
||||||
|
from datetime import datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
from dict2xml import dict2xml
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models, DataError
|
from django.db import models, DataError
|
||||||
from django.db.models import Sum, F
|
from django.db.models import Sum, F
|
||||||
@ -32,7 +36,7 @@ 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 core.models import Group, User
|
||||||
from counter.models import Counter, Product, Selling, Refilling
|
from counter.models import Counter, Product, Selling, Refilling, BillingInfo, Customer
|
||||||
|
|
||||||
|
|
||||||
def get_eboutic_products(user: User) -> List[Product]:
|
def get_eboutic_products(user: User) -> List[Product]:
|
||||||
@ -104,7 +108,7 @@ class Basket(models.Model):
|
|||||||
"""
|
"""
|
||||||
Remove all items from this basket without deleting the basket
|
Remove all items from this basket without deleting the basket
|
||||||
"""
|
"""
|
||||||
BasketItem.objects.filter(basket=self).delete()
|
self.items.all().delete()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def contains_refilling_item(self) -> bool:
|
def contains_refilling_item(self) -> bool:
|
||||||
@ -122,7 +126,7 @@ class Basket(models.Model):
|
|||||||
def from_session(cls, session) -> typing.Union["Basket", None]:
|
def from_session(cls, session) -> typing.Union["Basket", None]:
|
||||||
"""
|
"""
|
||||||
Given an HttpRequest django object, return the basket used in the current session
|
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 it exists else None
|
||||||
"""
|
"""
|
||||||
if "basket_id" in session:
|
if "basket_id" in session:
|
||||||
try:
|
try:
|
||||||
@ -131,6 +135,88 @@ class Basket(models.Model):
|
|||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def generate_sales(self, counter, seller: User, payment_method: str):
|
||||||
|
"""
|
||||||
|
Generate a list of sold items corresponding to the items
|
||||||
|
of this basket WITHOUT saving them NOR deleting the basket
|
||||||
|
|
||||||
|
Example:
|
||||||
|
::
|
||||||
|
|
||||||
|
counter = Counter.objects.get(name="Eboutic")
|
||||||
|
sales = basket.generate_sales(counter, "SITH_ACCOUNT")
|
||||||
|
# here the basket is in the same state as before the method call
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for sale in sales:
|
||||||
|
sale.save()
|
||||||
|
basket.delete()
|
||||||
|
# all the basket items are deleted by the on_delete=CASCADE relation
|
||||||
|
# thus only the sales remain
|
||||||
|
"""
|
||||||
|
# I must proceed with two distinct requests instead of
|
||||||
|
# only one with a join because the AbstractBaseItem model has been
|
||||||
|
# poorly designed. If you refactor the model, please refactor this too.
|
||||||
|
items = self.items.order_by("product_id")
|
||||||
|
ids = [item.product_id for item in items]
|
||||||
|
products = Product.objects.filter(id__in=ids).order_by("id")
|
||||||
|
# items and products are sorted in the same order
|
||||||
|
sales = []
|
||||||
|
for item, product in zip(items, products):
|
||||||
|
sales.append(
|
||||||
|
Selling(
|
||||||
|
label=product.name,
|
||||||
|
counter=counter,
|
||||||
|
club=product.club,
|
||||||
|
product=product,
|
||||||
|
seller=seller,
|
||||||
|
customer=self.user.customer,
|
||||||
|
unit_price=item.product_unit_price,
|
||||||
|
quantity=item.quantity,
|
||||||
|
payment_method=payment_method,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sales
|
||||||
|
|
||||||
|
def get_e_transaction_data(self):
|
||||||
|
user = self.user
|
||||||
|
if not hasattr(user, "customer"):
|
||||||
|
raise Customer.DoesNotExist
|
||||||
|
customer = user.customer
|
||||||
|
if not hasattr(user.customer, "billing_infos"):
|
||||||
|
raise BillingInfo.DoesNotExist
|
||||||
|
data = [
|
||||||
|
("PBX_SITE", settings.SITH_EBOUTIC_PBX_SITE),
|
||||||
|
("PBX_RANG", settings.SITH_EBOUTIC_PBX_RANG),
|
||||||
|
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
|
||||||
|
("PBX_TOTAL", str(int(self.get_total() * 100))),
|
||||||
|
("PBX_DEVISE", "978"), # This is Euro
|
||||||
|
("PBX_CMD", str(self.id)),
|
||||||
|
("PBX_PORTEUR", user.email),
|
||||||
|
("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"),
|
||||||
|
("PBX_HASH", "SHA512"),
|
||||||
|
("PBX_TYPEPAIEMENT", "CARTE"),
|
||||||
|
("PBX_TYPECARTE", "CB"),
|
||||||
|
("PBX_TIME", datetime.now().replace(microsecond=0).isoformat("T")),
|
||||||
|
]
|
||||||
|
cart = {
|
||||||
|
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
|
||||||
|
}
|
||||||
|
cart = '<?xml version="1.0" encoding="UTF-8" ?>' + dict2xml(
|
||||||
|
cart, newlines=False
|
||||||
|
)
|
||||||
|
data += [
|
||||||
|
("PBX_SHOPPINGCART", cart),
|
||||||
|
("PBX_BILLING", customer.billing_infos.to_3dsv2_xml()),
|
||||||
|
]
|
||||||
|
pbx_hmac = hmac.new(
|
||||||
|
settings.SITH_EBOUTIC_HMAC_KEY,
|
||||||
|
bytes("&".join("=".join(d) for d in data), "utf-8"),
|
||||||
|
"sha512",
|
||||||
|
)
|
||||||
|
data.append(("PBX_HMAC", pbx_hmac.hexdigest().upper()))
|
||||||
|
return data
|
||||||
|
|
||||||
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())
|
||||||
|
|
||||||
@ -156,18 +242,9 @@ class Invoice(models.Model):
|
|||||||
)["total"]
|
)["total"]
|
||||||
return float(total) if total is not None else 0
|
return float(total) if total is not None else 0
|
||||||
|
|
||||||
def validate(self, *args, **kwargs):
|
def validate(self):
|
||||||
if self.validated:
|
if self.validated:
|
||||||
raise DataError(_("Invoice already validated"))
|
raise DataError(_("Invoice already validated"))
|
||||||
from counter.models import Customer
|
|
||||||
|
|
||||||
if not Customer.objects.filter(user=self.user).exists():
|
|
||||||
number = Customer.objects.count() + 1
|
|
||||||
Customer(
|
|
||||||
user=self.user,
|
|
||||||
account_id=Customer.generate_account_id(number),
|
|
||||||
amount=0,
|
|
||||||
).save()
|
|
||||||
eboutic = Counter.objects.filter(type="EBOUTIC").first()
|
eboutic = Counter.objects.filter(type="EBOUTIC").first()
|
||||||
for i in self.items.all():
|
for i in self.items.all():
|
||||||
if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
|
if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
|
||||||
@ -227,6 +304,22 @@ class BasketItem(AbstractBaseItem):
|
|||||||
Basket, related_name="items", verbose_name=_("basket"), on_delete=models.CASCADE
|
Basket, related_name="items", verbose_name=_("basket"), on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_product(cls, product: Product, quantity: int):
|
||||||
|
"""
|
||||||
|
Create a BasketItem with the same characteristics as the
|
||||||
|
product passed in parameters, with the specified quantity
|
||||||
|
WARNING : the basket field is not filled, so you must set
|
||||||
|
it yourself before saving the model
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
product_id=product.id,
|
||||||
|
product_name=product.name,
|
||||||
|
type_id=product.product_type.id,
|
||||||
|
quantity=quantity,
|
||||||
|
product_unit_price=product.selling_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InvoiceItem(AbstractBaseItem):
|
class InvoiceItem(AbstractBaseItem):
|
||||||
invoice = models.ForeignKey(
|
invoice = models.ForeignKey(
|
||||||
|
@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
@media screen and (max-width: 765px) {
|
@media screen and (max-width: 765px) {
|
||||||
#eboutic {
|
#eboutic {
|
||||||
flex-direction: column;
|
flex-direction: column-reverse;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 10px;
|
margin: 10px;
|
||||||
row-gap: 20px;
|
row-gap: 20px;
|
||||||
@ -38,23 +38,10 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
#basket {
|
#basket {
|
||||||
|
width: -webkit-fill-available;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#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 {
|
#eboutic .item-list {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@ -66,23 +53,39 @@
|
|||||||
margin-bottom: 10px
|
margin-bottom: 10px
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#eboutic .item-row {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
#eboutic .item-name {
|
#eboutic .item-name {
|
||||||
flex: 1;
|
word-break: break-word;
|
||||||
|
width: 100%;
|
||||||
|
line-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#eboutic .item-price, #eboutic .item-quantity {
|
#eboutic .fa-plus,
|
||||||
width: 65px;
|
#eboutic .fa-minus {
|
||||||
}
|
cursor: pointer;
|
||||||
|
background-color: #354a5f;
|
||||||
#eboutic .item-quantity {
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 10px;
|
||||||
|
width: 10px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#eboutic .fa-plus, #eboutic .fa-minus {
|
#eboutic .item-quantity {
|
||||||
cursor: pointer;
|
min-width: 65px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#eboutic .item-price {
|
#eboutic .item-price {
|
||||||
|
min-width: 65px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,44 +108,65 @@
|
|||||||
column-gap: 15px;
|
column-gap: 15px;
|
||||||
row-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 {
|
#eboutic .product-button {
|
||||||
position: relative;
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 120px;
|
min-height: 180px;
|
||||||
max-width: 150px;
|
height: fit-content;
|
||||||
padding: 10px;
|
width: 150px;
|
||||||
|
padding: 15px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px;
|
box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
row-gap: 5px;
|
row-gap: 5px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .product-button.selected {
|
||||||
|
animation: bg-in-out 1s ease;
|
||||||
|
background-color: rgb(216, 236, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .product-button.selected::after {
|
||||||
|
content: "🛒";
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0px 0px 12px 2px rgb(0 0 0 / 14%);
|
||||||
|
background-color: white;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#eboutic .product-button:active {
|
#eboutic .product-button:active {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#eboutic .product-button img, #eboutic .product-button .fa {
|
#eboutic .product-image {
|
||||||
margin: 0;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 70px;
|
||||||
|
max-height: 70px;
|
||||||
|
object-fit: contain;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
height: 40px;
|
line-height: 70px;
|
||||||
line-height: 40px;
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic i.product-image {
|
||||||
|
background-color: rgba(173, 173, 173, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .product-description h4 {
|
||||||
|
font-size: .75em;
|
||||||
|
word-break: break-word;
|
||||||
|
margin: 0 0 5px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#eboutic .product-button p {
|
#eboutic .product-button p {
|
||||||
@ -162,12 +186,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#eboutic .catalog-buttons button {
|
#eboutic .catalog-buttons button {
|
||||||
font-size: 15px;
|
font-size: 15px!important;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
color: white;
|
color: white;
|
||||||
min-width: 60px;
|
min-width: 60px;
|
||||||
padding: 5px 10px;
|
padding: 10px 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#eboutic .catalog-buttons .validate {
|
#eboutic .catalog-buttons .validate {
|
||||||
background-color: #354a5f;
|
background-color: #354a5f;
|
||||||
}
|
}
|
||||||
@ -189,3 +214,50 @@
|
|||||||
#eboutic .catalog-buttons form {
|
#eboutic .catalog-buttons form {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 765px) {
|
||||||
|
#eboutic #catalog {
|
||||||
|
row-gap: 15px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic section {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .product-group {
|
||||||
|
justify-content: space-around;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .product-group .product-button {
|
||||||
|
min-height: 100px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .product-group .product-description {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .product-description h4 {
|
||||||
|
text-align: left;
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .product-image {
|
||||||
|
margin-bottom: 0px;
|
||||||
|
max-width: 70px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bg-in-out {
|
||||||
|
0% { background-color: white; }
|
||||||
|
100% { background-color: rgb(216, 236, 255); }
|
||||||
|
}
|
157
eboutic/static/eboutic/js/eboutic.js
Normal file
157
eboutic/static/eboutic/js/eboutic.js
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {Object} BasketItem An item in the basket
|
||||||
|
* @property {number} id The id of the product
|
||||||
|
* @property {string} name The name of the product
|
||||||
|
* @property {number} quantity The quantity of the product
|
||||||
|
* @property {number} unit_price The unit price of the product
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASKET_ITEMS_COOKIE_NAME = "basket_items";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for a cookie by name
|
||||||
|
* @param {string} name Name of the cookie to get
|
||||||
|
* @returns {string|null|undefined} the value of the cookie or null if it does not exist, undefined if not found
|
||||||
|
*/
|
||||||
|
function getCookie(name) {
|
||||||
|
if (!document.cookie || document.cookie.length === 0) return null;
|
||||||
|
|
||||||
|
let found = document.cookie
|
||||||
|
.split(';')
|
||||||
|
.map(c => c.trim())
|
||||||
|
.find(c => c.startsWith(name + '='));
|
||||||
|
|
||||||
|
return found === undefined ? undefined : decodeURIComponent(found.split('=')[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the basket items from the associated cookie
|
||||||
|
* @returns {BasketItem[]|[]} the items in the basket
|
||||||
|
*/
|
||||||
|
function get_starting_items() {
|
||||||
|
const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
|
||||||
|
let output = [];
|
||||||
|
|
||||||
|
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(cookie.replace(/\\054/g, ','));
|
||||||
|
output = Array.isArray(biscuit) ? biscuit : [];
|
||||||
|
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
output.forEach(item => {
|
||||||
|
let el = document.getElementById(item.id);
|
||||||
|
el.classList.add("selected");
|
||||||
|
});
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('basket', () => ({
|
||||||
|
items: get_starting_items(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the total price of the basket
|
||||||
|
* @returns {number} The total price of the basket
|
||||||
|
*/
|
||||||
|
get_total() {
|
||||||
|
return this.items
|
||||||
|
.reduce((acc, item) => acc + item["quantity"] * item["unit_price"], 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add 1 to the quantity of an item in the basket
|
||||||
|
* @param {BasketItem} item
|
||||||
|
*/
|
||||||
|
add(item) {
|
||||||
|
item.quantity++;
|
||||||
|
this.set_cookies();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove 1 to the quantity of an item in the basket
|
||||||
|
* @param {BasketItem} item_id
|
||||||
|
*/
|
||||||
|
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) {
|
||||||
|
let el = document.getElementById(this.items[index].id);
|
||||||
|
el.classList.remove("selected");
|
||||||
|
|
||||||
|
this.items = this.items.filter((e) => e.id !== this.items[index].id);
|
||||||
|
}
|
||||||
|
this.set_cookies();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all the items from the basket & cleans the catalog CSS classes
|
||||||
|
*/
|
||||||
|
clear_basket() {
|
||||||
|
// We remove the class "selected" from all the items in the catalog
|
||||||
|
this.items.forEach(item => {
|
||||||
|
let el = document.getElementById(item.id);
|
||||||
|
el.classList.remove("selected");
|
||||||
|
})
|
||||||
|
|
||||||
|
this.items = [];
|
||||||
|
this.set_cookies();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the cookie in the browser with the basket items
|
||||||
|
* ! the cookie survives an hour
|
||||||
|
*/
|
||||||
|
set_cookies() {
|
||||||
|
if (this.items.length === 0) document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`;
|
||||||
|
else document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an item in the basket if it was not already in
|
||||||
|
* @param {number} id The id of the product to add
|
||||||
|
* @param {string} name The name of the product
|
||||||
|
* @param {number} price The unit price of the product
|
||||||
|
* @returns {BasketItem} The created item
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
|
||||||
|
return new_item;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an item to the basket.
|
||||||
|
* This is called when the user click on a button in the catalog
|
||||||
|
* @param {number} id The id of the product to add
|
||||||
|
* @param {string} name The name of the product
|
||||||
|
* @param {number} price The unit price of the product
|
||||||
|
*/
|
||||||
|
add_from_catalog(id, name, price) {
|
||||||
|
let item = this.items.find(e => e.id === id)
|
||||||
|
|
||||||
|
// if the item is not in the basket, we create it
|
||||||
|
// else we add + 1 to it
|
||||||
|
if (item === undefined) item = this.create_item(id, name, price);
|
||||||
|
else this.add(item);
|
||||||
|
|
||||||
|
if (item.quantity > 0) {
|
||||||
|
let el = document.getElementById(item.id);
|
||||||
|
el.classList.add("selected");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
73
eboutic/static/eboutic/js/makecommand.js
Normal file
73
eboutic/static/eboutic/js/makecommand.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.store('bank_payment_enabled', false)
|
||||||
|
|
||||||
|
Alpine.store('billing_inputs', {
|
||||||
|
data: JSON.parse(et_data)["data"],
|
||||||
|
|
||||||
|
async fill() {
|
||||||
|
document.getElementById("bank-submit-button").disabled = true;
|
||||||
|
const request = new Request(et_data_url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = await fetch(request);
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
if (json["data"]) {
|
||||||
|
this.data = json["data"];
|
||||||
|
}
|
||||||
|
document.getElementById("bank-submit-button").disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Alpine.data('billing_infos', () => ({
|
||||||
|
errors: [],
|
||||||
|
successful: false,
|
||||||
|
url: billing_info_exist ? edit_billing_info_url : create_billing_info_url,
|
||||||
|
|
||||||
|
async send_form() {
|
||||||
|
const form = document.getElementById("billing_info_form");
|
||||||
|
const submit_button = form.querySelector("input[type=submit]")
|
||||||
|
submit_button.disabled = true;
|
||||||
|
document.getElementById("bank-submit-button").disabled = true;
|
||||||
|
this.successful = false
|
||||||
|
|
||||||
|
let payload = {};
|
||||||
|
for (const elem of form.querySelectorAll("input")) {
|
||||||
|
if (elem.type === "text" && elem.value) {
|
||||||
|
payload[elem.name] = elem.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const country = form.querySelector("select");
|
||||||
|
if (country && country.value) {
|
||||||
|
payload[country.name] = country.value;
|
||||||
|
}
|
||||||
|
const request = new Request(this.url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': getCSRFToken(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const res = await fetch(request);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json["errors"]) {
|
||||||
|
this.errors = json["errors"];
|
||||||
|
} else {
|
||||||
|
this.errors = [];
|
||||||
|
this.successful = true;
|
||||||
|
this.url = edit_billing_info_url;
|
||||||
|
Alpine.store("billing_inputs").fill();
|
||||||
|
}
|
||||||
|
submit_button.disabled = false;
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
|
@ -25,12 +25,14 @@
|
|||||||
<div id="basket">
|
<div id="basket">
|
||||||
<h3>Panier</h3>
|
<h3>Panier</h3>
|
||||||
{% if errors %}
|
{% if errors %}
|
||||||
<div class="error-message">
|
<div class="alert alert-red">
|
||||||
|
<div class="alert-main">
|
||||||
{% for error in errors %}
|
{% for error in errors %}
|
||||||
<p>{{ error }}</p>
|
<p style="margin: 0">{{ error }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% trans %}Your basket has been cleaned accordingly to those errors.{% endtrans %}
|
{% trans %}Your basket has been cleaned accordingly to those errors.{% endtrans %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<ul class="item-list">
|
<ul class="item-list">
|
||||||
{# Starting money #}
|
{# Starting money #}
|
||||||
@ -44,12 +46,12 @@
|
|||||||
</li>
|
</li>
|
||||||
<template x-for="item in items" :key="item.id">
|
<template x-for="item in items" :key="item.id">
|
||||||
<li class="item-row" x-show="item.quantity > 0">
|
<li class="item-row" x-show="item.quantity > 0">
|
||||||
<span class="item-name" x-text="item.name"></span>
|
|
||||||
<div class="item-quantity">
|
<div class="item-quantity">
|
||||||
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
|
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
|
||||||
<span x-text="item.quantity"></span>
|
<span x-text="item.quantity"></span>
|
||||||
<i class="fa fa-plus" @click="add(item)"></i>
|
<i class="fa fa-plus" @click="add(item)"></i>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="item-name" x-text="item.name"></span>
|
||||||
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
|
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
@ -64,7 +66,7 @@
|
|||||||
<i class="fa fa-trash"></i>
|
<i class="fa fa-trash"></i>
|
||||||
{% trans %}Clear{% endtrans %}
|
{% trans %}Clear{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
<form method="post" action="{{ url('eboutic:command') }}">
|
<form method="get" action="{{ url('eboutic:command') }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button class="validate">
|
<button class="validate">
|
||||||
<i class="fa fa-check"></i>
|
<i class="fa fa-check"></i>
|
||||||
@ -75,7 +77,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="catalog">
|
<div id="catalog">
|
||||||
{% if not request.user.date_of_birth %}
|
{% if not request.user.date_of_birth %}
|
||||||
<div class="alert" x-data="{show_alert: true}" x-show="show_alert" x-transition>
|
<div class="alert alert-red" x-data="{show_alert: true}" x-show="show_alert" x-transition>
|
||||||
<span class="alert-main">
|
<span class="alert-main">
|
||||||
{% trans %}You have not filled in your date of birth. As a result, you may not have access to all the products in the online shop. To fill in your date of birth, you can go to{% endtrans %}
|
{% trans %}You have not filled in your date of birth. As a result, you may not have access to all the products in the online shop. To fill in your date of birth, you can go to{% endtrans %}
|
||||||
<a href="{{ url("core:user_edit", user_id=request.user.id) }}">
|
<a href="{{ url("core:user_edit", user_id=request.user.id) }}">
|
||||||
@ -102,16 +104,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="product-group">
|
<div class="product-group">
|
||||||
{% for p in items %}
|
{% for p in items %}
|
||||||
<button class="product-button"
|
<button id="{{ p.id }}" class="product-button" @click='add_from_catalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'>
|
||||||
@click='add_from_catalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'>
|
|
||||||
{% if p.icon %}
|
{% if p.icon %}
|
||||||
<img src="{{ p.icon.url }}" alt="image de {{ p.name }}"
|
<img class="product-image" src="{{ p.icon.url }}" alt="image de {{ p.name }}">
|
||||||
width="40px" height="40px">
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<i class="fa fa-2x fa-picture-o"></i>
|
<i class="fa fa-2x fa-picture-o product-image" ></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p><strong>{{ p.name }}</strong></p>
|
<div class="product-description">
|
||||||
|
<h4>{{ p.name }}</strong></h4>
|
||||||
<p>{{ p.selling_price }} €</p>
|
<p>{{ p.selling_price }} €</p>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,13 +1,22 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% trans %}Basket state{% endtrans %}
|
{% trans %}Basket state{% endtrans %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block jquery_css %}
|
||||||
|
{# Remove jquery css #}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block additional_js %}
|
||||||
|
<script src="{{ static('eboutic/js/makecommand.js') }}" defer></script>
|
||||||
|
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p>{% trans %}Basket: {% endtrans %}</p>
|
<p>{% trans %}Basket: {% endtrans %}</p>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@ -33,7 +42,8 @@
|
|||||||
|
|
||||||
{% if customer_amount != None %}
|
{% if customer_amount != None %}
|
||||||
<br>
|
<br>
|
||||||
{% trans %}Current account amount: {% endtrans %}<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
{% trans %}Current account amount: {% endtrans %}
|
||||||
|
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
||||||
|
|
||||||
{% if not basket.contains_refilling_item %}
|
{% if not basket.contains_refilling_item %}
|
||||||
<br>
|
<br>
|
||||||
@ -42,28 +52,90 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
<br>
|
||||||
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
|
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
|
||||||
<form method="post" action="{{ settings.SITH_EBOUTIC_ET_URL }}">
|
<div class="collapse" :class="{'shadow': collapsed}" x-data="{collapsed: false}" x-cloak>
|
||||||
|
<div class="collapse-header clickable" @click="collapsed = !collapsed">
|
||||||
|
<span class="collapse-header-text">
|
||||||
|
{% trans %}Edit billing information{% endtrans %}
|
||||||
|
</span>
|
||||||
|
<span class="collapse-header-icon" :class="{'reverse': collapsed}">
|
||||||
|
<i class="fa fa-caret-down"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<form class="collapse-body" id="billing_info_form" method="post"
|
||||||
|
x-show="collapsed" x-data="billing_infos"
|
||||||
|
x-transition.scale.origin.top
|
||||||
|
@submit.prevent="send_form()">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ billing_form }}
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<div x-show="errors.length > 0" class="alert alert-red" x-transition>
|
||||||
|
<div class="alert-main">
|
||||||
|
<template x-for="error in errors">
|
||||||
|
<div x-text="error.field + ' : ' + error.messages.join(', ')"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="clickable" @click="errors = []">
|
||||||
|
<i class="fa fa-close"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="successful" class="alert alert-green" x-transition>
|
||||||
|
<div class="alert-main">
|
||||||
|
Informations de facturation enregistrées
|
||||||
|
</div>
|
||||||
|
<div class="clickable" @click="successful = false">
|
||||||
|
<i class="fa fa-close"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="submit" class="btn btn-blue clickable"
|
||||||
|
value="{% trans %}Validate{% endtrans %}">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
{% if must_fill_billing_infos %}
|
||||||
<p>
|
<p>
|
||||||
{% for (field_name,field_value) in et_request.items() -%}
|
<i>
|
||||||
<input type="hidden" name="{{ field_name }}" value="{{ field_value }}">
|
{% trans %}You must fill your billing infos if you want to pay with your credit
|
||||||
{% endfor %}
|
card{% endtrans %}
|
||||||
<input type="submit" value="{% trans %}Pay with credit card{% endtrans %}" />
|
</i>
|
||||||
</p>
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="{{ settings.SITH_EBOUTIC_ET_URL }}" name="bank-pay-form">
|
||||||
|
<template x-data x-for="input in $store.billing_inputs.data">
|
||||||
|
<input type="hidden" :name="input['key']" :value="input['value']">
|
||||||
|
</template>
|
||||||
|
<input type="submit" id="bank-submit-button"
|
||||||
|
{% if must_fill_billing_infos %}disabled="disabled"{% endif %}
|
||||||
|
value="{% trans %}Pay with credit card{% endtrans %}"/>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if basket.contains_refilling_item %}
|
{% if basket.contains_refilling_item %}
|
||||||
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>
|
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="{{ url('eboutic:pay_with_sith') }}">
|
<form method="post" action="{{ url('eboutic:pay_with_sith') }}" name="sith-pay-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="pay_with_sith_account">
|
<input type="hidden" name="action" value="pay_with_sith_account">
|
||||||
<input type="submit" value="{% trans %}Pay with Sith account{% endtrans %}" />
|
<input type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
const create_billing_info_url = '{{ url("counter:create_billing_info", user_id=request.user.id) }}'
|
||||||
|
const edit_billing_info_url = '{{ url("counter:edit_billing_info", user_id=request.user.id) }}';
|
||||||
|
const et_data_url = '{{ url("eboutic:et_data") }}'
|
||||||
|
let billing_info_exist = {{ "true" if billing_infos else "false" }}
|
||||||
|
|
||||||
|
{% if billing_infos %}
|
||||||
|
const et_data = {{ billing_infos|tojson }}
|
||||||
|
{% else %}
|
||||||
|
const et_data = '{"data": []}'
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
{{ super() }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -24,7 +24,6 @@
|
|||||||
#
|
#
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
import urllib
|
import urllib
|
||||||
|
|
||||||
from OpenSSL import crypto
|
from OpenSSL import crypto
|
||||||
@ -40,18 +39,19 @@ from eboutic.models import Basket
|
|||||||
|
|
||||||
|
|
||||||
class EbouticTest(TestCase):
|
class EbouticTest(TestCase):
|
||||||
def setUp(self):
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
call_command("populate")
|
call_command("populate")
|
||||||
self.skia = User.objects.filter(username="skia").first()
|
cls.barbar = Product.objects.filter(code="BARB").first()
|
||||||
self.subscriber = User.objects.filter(username="subscriber").first()
|
cls.refill = Product.objects.filter(code="15REFILL").first()
|
||||||
self.old_subscriber = User.objects.filter(username="old_subscriber").first()
|
cls.cotis = Product.objects.filter(code="1SCOTIZ").first()
|
||||||
self.public = User.objects.filter(username="public").first()
|
cls.eboutic = Counter.objects.filter(name="Eboutic").first()
|
||||||
self.barbar = Product.objects.filter(code="BARB").first()
|
cls.skia = User.objects.filter(username="skia").first()
|
||||||
self.refill = Product.objects.filter(code="15REFILL").first()
|
cls.subscriber = User.objects.filter(username="subscriber").first()
|
||||||
self.cotis = Product.objects.filter(code="1SCOTIZ").first()
|
cls.old_subscriber = User.objects.filter(username="old_subscriber").first()
|
||||||
self.eboutic = Counter.objects.filter(name="Eboutic").first()
|
cls.public = User.objects.filter(username="public").first()
|
||||||
|
|
||||||
def get_busy_basket(self, user):
|
def get_busy_basket(self, user) -> Basket:
|
||||||
"""
|
"""
|
||||||
Create and return a basket with 3 barbar and 1 cotis in it.
|
Create and return a basket with 3 barbar and 1 cotis in it.
|
||||||
Edit the client session to store the basket id in it
|
Edit the client session to store the basket id in it
|
||||||
@ -64,11 +64,11 @@ class EbouticTest(TestCase):
|
|||||||
basket.add_product(self.cotis)
|
basket.add_product(self.cotis)
|
||||||
return basket
|
return basket
|
||||||
|
|
||||||
def generate_bank_valid_answer_from_page_content(self, content):
|
def generate_bank_valid_answer(self) -> str:
|
||||||
content = str(content)
|
basket = Basket.from_session(self.client.session)
|
||||||
basket_id = re.search(r"PBX_CMD\" value=\"(\d*)\"", content).group(1)
|
basket_id = basket.id
|
||||||
amount = re.search(r"PBX_TOTAL\" value=\"(\d*)\"", content).group(1)
|
amount = int(basket.get_total() * 100)
|
||||||
query = "Amount=%s&BasketID=%s&Auto=42&Error=00000" % (amount, basket_id)
|
query = f"Amount={amount}&BasketID={basket_id}&Auto=42&Error=00000"
|
||||||
with open("./eboutic/tests/private_key.pem") as f:
|
with open("./eboutic/tests/private_key.pem") as f:
|
||||||
PRIVKEY = f.read()
|
PRIVKEY = f.read()
|
||||||
with open("./eboutic/tests/public_key.pem") as f:
|
with open("./eboutic/tests/public_key.pem") as f:
|
||||||
@ -81,8 +81,7 @@ class EbouticTest(TestCase):
|
|||||||
query,
|
query,
|
||||||
urllib.parse.quote_plus(b64sig),
|
urllib.parse.quote_plus(b64sig),
|
||||||
)
|
)
|
||||||
response = self.client.get(url)
|
return url
|
||||||
return response
|
|
||||||
|
|
||||||
def test_buy_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")
|
||||||
@ -102,7 +101,7 @@ class EbouticTest(TestCase):
|
|||||||
def test_buy_with_sith_account_no_money(self):
|
def test_buy_with_sith_account_no_money(self):
|
||||||
self.client.login(username="subscriber", password="plop")
|
self.client.login(username="subscriber", password="plop")
|
||||||
basket = self.get_busy_basket(self.subscriber)
|
basket = self.get_busy_basket(self.subscriber)
|
||||||
initial = basket.get_total() - 1
|
initial = basket.get_total() - 1 # just not enough to complete the sale
|
||||||
self.subscriber.customer.amount = initial
|
self.subscriber.customer.amount = initial
|
||||||
self.subscriber.customer.save()
|
self.subscriber.customer.save()
|
||||||
response = self.client.post(reverse("eboutic:pay_with_sith"))
|
response = self.client.post(reverse("eboutic:pay_with_sith"))
|
||||||
@ -122,7 +121,7 @@ class EbouticTest(TestCase):
|
|||||||
{"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28},
|
{"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28},
|
||||||
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
|
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
|
||||||
]"""
|
]"""
|
||||||
response = self.client.post(reverse("eboutic:command"))
|
response = self.client.get(reverse("eboutic:command"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
|
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
|
||||||
@ -146,7 +145,7 @@ class EbouticTest(TestCase):
|
|||||||
def test_submit_empty_basket(self):
|
def test_submit_empty_basket(self):
|
||||||
self.client.login(username="subscriber", password="plop")
|
self.client.login(username="subscriber", password="plop")
|
||||||
self.client.cookies["basket_items"] = "[]"
|
self.client.cookies["basket_items"] = "[]"
|
||||||
response = self.client.post(reverse("eboutic:command"))
|
response = self.client.get(reverse("eboutic:command"))
|
||||||
self.assertRedirects(response, "/eboutic/")
|
self.assertRedirects(response, "/eboutic/")
|
||||||
|
|
||||||
def test_submit_invalid_basket(self):
|
def test_submit_invalid_basket(self):
|
||||||
@ -157,7 +156,7 @@ class EbouticTest(TestCase):
|
|||||||
] = f"""[
|
] = f"""[
|
||||||
{{"id": {max_id + 1}, "name": "", "quantity": 1, "unit_price": 28}}
|
{{"id": {max_id + 1}, "name": "", "quantity": 1, "unit_price": 28}}
|
||||||
]"""
|
]"""
|
||||||
response = self.client.post(reverse("eboutic:command"))
|
response = self.client.get(reverse("eboutic:command"))
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
'basket_items=""',
|
'basket_items=""',
|
||||||
self.client.cookies["basket_items"].OutputString(),
|
self.client.cookies["basket_items"].OutputString(),
|
||||||
@ -175,7 +174,7 @@ class EbouticTest(TestCase):
|
|||||||
] = """[
|
] = """[
|
||||||
{"id": 4, "name": "Barbar", "quantity": -1, "unit_price": 1.7}
|
{"id": 4, "name": "Barbar", "quantity": -1, "unit_price": 1.7}
|
||||||
]"""
|
]"""
|
||||||
response = self.client.post(reverse("eboutic:command"))
|
response = self.client.get(reverse("eboutic:command"))
|
||||||
self.assertRedirects(response, "/eboutic/")
|
self.assertRedirects(response, "/eboutic/")
|
||||||
|
|
||||||
def test_buy_subscribe_product_with_credit_card(self):
|
def test_buy_subscribe_product_with_credit_card(self):
|
||||||
@ -189,14 +188,14 @@ class EbouticTest(TestCase):
|
|||||||
] = """[
|
] = """[
|
||||||
{"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28}
|
{"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28}
|
||||||
]"""
|
]"""
|
||||||
response = self.client.post(reverse("eboutic:command"))
|
response = self.client.get(reverse("eboutic:command"))
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
|
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
|
||||||
response.content.decode(),
|
response.content.decode(),
|
||||||
)
|
)
|
||||||
basket = Basket.objects.get(id=self.client.session["basket_id"])
|
basket = Basket.objects.get(id=self.client.session["basket_id"])
|
||||||
self.assertEqual(basket.items.count(), 1)
|
self.assertEqual(basket.items.count(), 1)
|
||||||
response = self.generate_bank_valid_answer_from_page_content(response.content)
|
response = self.client.get(self.generate_bank_valid_answer())
|
||||||
self.assertTrue(response.status_code == 200)
|
self.assertTrue(response.status_code == 200)
|
||||||
self.assertTrue(response.content.decode("utf-8") == "Payment successful")
|
self.assertTrue(response.content.decode("utf-8") == "Payment successful")
|
||||||
|
|
||||||
@ -215,9 +214,10 @@ class EbouticTest(TestCase):
|
|||||||
[{"id": 3, "name": "Rechargement 15 €", "quantity": 1, "unit_price": 15}]
|
[{"id": 3, "name": "Rechargement 15 €", "quantity": 1, "unit_price": 15}]
|
||||||
)
|
)
|
||||||
initial_balance = self.subscriber.customer.amount
|
initial_balance = self.subscriber.customer.amount
|
||||||
response = self.client.post(reverse("eboutic:command"))
|
self.client.get(reverse("eboutic:command"))
|
||||||
|
|
||||||
response = self.generate_bank_valid_answer_from_page_content(response.content)
|
url = self.generate_bank_valid_answer()
|
||||||
|
response = self.client.get(url)
|
||||||
self.assertTrue(response.status_code == 200)
|
self.assertTrue(response.status_code == 200)
|
||||||
self.assertTrue(response.content.decode() == "Payment successful")
|
self.assertTrue(response.content.decode() == "Payment successful")
|
||||||
new_balance = Customer.objects.get(user=self.subscriber).amount
|
new_balance = Customer.objects.get(user=self.subscriber).amount
|
||||||
@ -228,14 +228,15 @@ class EbouticTest(TestCase):
|
|||||||
self.client.cookies["basket_items"] = json.dumps(
|
self.client.cookies["basket_items"] = json.dumps(
|
||||||
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
|
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
|
||||||
)
|
)
|
||||||
response = self.client.post(reverse("eboutic:command"))
|
self.client.get(reverse("eboutic:command"))
|
||||||
|
et_answer_url = self.generate_bank_valid_answer()
|
||||||
self.client.cookies["basket_items"] = json.dumps(
|
self.client.cookies["basket_items"] = json.dumps(
|
||||||
[ # alter basket
|
[ # alter basket
|
||||||
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
|
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
self.client.post(reverse("eboutic:command"))
|
self.client.get(reverse("eboutic:command"))
|
||||||
response = self.generate_bank_valid_answer_from_page_content(response.content)
|
response = self.client.get(et_answer_url)
|
||||||
self.assertEqual(response.status_code, 500)
|
self.assertEqual(response.status_code, 500)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"Basket processing failed with error: SuspiciousOperation('Basket total and amount do not match'",
|
"Basket processing failed with error: SuspiciousOperation('Basket total and amount do not match'",
|
||||||
@ -247,8 +248,9 @@ class EbouticTest(TestCase):
|
|||||||
self.client.cookies["basket_items"] = json.dumps(
|
self.client.cookies["basket_items"] = json.dumps(
|
||||||
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
|
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
|
||||||
)
|
)
|
||||||
response = self.client.post(reverse("eboutic:command"))
|
self.client.get(reverse("eboutic:command"))
|
||||||
response = self.generate_bank_valid_answer_from_page_content(response.content)
|
et_answer_url = self.generate_bank_valid_answer()
|
||||||
|
response = self.client.get(et_answer_url)
|
||||||
self.assertTrue(response.status_code == 200)
|
self.assertTrue(response.status_code == 200)
|
||||||
self.assertTrue(response.content.decode("utf-8") == "Payment successful")
|
self.assertTrue(response.content.decode("utf-8") == "Payment successful")
|
||||||
|
|
||||||
|
@ -34,8 +34,9 @@ urlpatterns = [
|
|||||||
# Subscription views
|
# Subscription views
|
||||||
path("", eboutic_main, name="main"),
|
path("", eboutic_main, name="main"),
|
||||||
path("command/", EbouticCommand.as_view(), name="command"),
|
path("command/", EbouticCommand.as_view(), name="command"),
|
||||||
path("pay/", pay_with_sith, name="pay_with_sith"),
|
path("pay/sith/", pay_with_sith, name="pay_with_sith"),
|
||||||
path("pay/<res:result>/", payment_result, name="payment_result"),
|
path("pay/<res:result>/", payment_result, name="payment_result"),
|
||||||
|
path("et_data/", e_transaction_data, name="et_data"),
|
||||||
path(
|
path(
|
||||||
"et_autoanswer",
|
"et_autoanswer",
|
||||||
EtransactionAutoAnswer.as_view(),
|
EtransactionAutoAnswer.as_view(),
|
||||||
|
116
eboutic/views.py
116
eboutic/views.py
@ -23,13 +23,11 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hmac
|
|
||||||
import json
|
import json
|
||||||
from collections import OrderedDict
|
|
||||||
from datetime import datetime
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import unquote
|
||||||
from OpenSSL import crypto
|
from OpenSSL import crypto
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
@ -41,7 +39,8 @@ from django.utils.decorators import method_decorator
|
|||||||
from django.views.decorators.http import require_GET, require_POST
|
from django.views.decorators.http import require_GET, require_POST
|
||||||
from django.views.generic import TemplateView, View
|
from django.views.generic import TemplateView, View
|
||||||
|
|
||||||
from counter.models import Customer, Counter, Selling
|
from counter.forms import BillingInfoForm
|
||||||
|
from counter.models import Customer, Counter, Product
|
||||||
from eboutic.forms import BasketForm
|
from eboutic.forms import BasketForm
|
||||||
from eboutic.models import Basket, Invoice, InvoiceItem, get_eboutic_products
|
from eboutic.models import Basket, Invoice, InvoiceItem, get_eboutic_products
|
||||||
|
|
||||||
@ -85,11 +84,11 @@ class EbouticCommand(TemplateView):
|
|||||||
template_name = "eboutic/eboutic_makecommand.jinja"
|
template_name = "eboutic/eboutic_makecommand.jinja"
|
||||||
|
|
||||||
@method_decorator(login_required)
|
@method_decorator(login_required)
|
||||||
def get(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
return redirect("eboutic:main")
|
return redirect("eboutic:main")
|
||||||
|
|
||||||
@method_decorator(login_required)
|
@method_decorator(login_required)
|
||||||
def post(self, request: HttpRequest, *args, **kwargs):
|
def get(self, request: HttpRequest, *args, **kwargs):
|
||||||
form = BasketForm(request)
|
form = BasketForm(request)
|
||||||
if not form.is_valid():
|
if not form.is_valid():
|
||||||
request.session["errors"] = form.get_error_messages()
|
request.session["errors"] = form.get_error_messages()
|
||||||
@ -98,65 +97,56 @@ class EbouticCommand(TemplateView):
|
|||||||
res.set_cookie("basket_items", form.get_cleaned_cookie(), path="/eboutic")
|
res.set_cookie("basket_items", form.get_cleaned_cookie(), path="/eboutic")
|
||||||
return res
|
return res
|
||||||
|
|
||||||
if "basket_id" in request.session:
|
basket = Basket.from_session(request.session)
|
||||||
basket, _ = Basket.objects.get_or_create(
|
if basket is not None:
|
||||||
id=request.session["basket_id"], user=request.user
|
|
||||||
)
|
|
||||||
basket.clear()
|
basket.clear()
|
||||||
else:
|
else:
|
||||||
basket = Basket.objects.create(user=request.user)
|
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["basket_id"] = basket.id
|
||||||
request.session.modified = True
|
request.session.modified = True
|
||||||
|
|
||||||
|
items = json.loads(unquote(request.COOKIES["basket_items"]))
|
||||||
|
items.sort(key=lambda item: item["id"])
|
||||||
|
ids = [item["id"] for item in items]
|
||||||
|
quantities = [item["quantity"] for item in items]
|
||||||
|
products = Product.objects.filter(id__in=ids)
|
||||||
|
for product, qty in zip(products, quantities):
|
||||||
|
basket.add_product(product, qty)
|
||||||
kwargs["basket"] = basket
|
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):
|
||||||
kwargs = super(EbouticCommand, self).get_context_data(**kwargs)
|
# basket is already in kwargs when the method is called
|
||||||
|
default_billing_info = None
|
||||||
if hasattr(self.request.user, "customer"):
|
if hasattr(self.request.user, "customer"):
|
||||||
kwargs["customer_amount"] = self.request.user.customer.amount
|
customer = self.request.user.customer
|
||||||
|
kwargs["customer_amount"] = customer.amount
|
||||||
|
if hasattr(customer, "billing_infos"):
|
||||||
|
default_billing_info = customer.billing_infos
|
||||||
else:
|
else:
|
||||||
kwargs["customer_amount"] = None
|
kwargs["customer_amount"] = None
|
||||||
kwargs["et_request"] = OrderedDict()
|
kwargs["must_fill_billing_infos"] = default_billing_info is None
|
||||||
kwargs["et_request"]["PBX_SITE"] = settings.SITH_EBOUTIC_PBX_SITE
|
if not kwargs["must_fill_billing_infos"]:
|
||||||
kwargs["et_request"]["PBX_RANG"] = settings.SITH_EBOUTIC_PBX_RANG
|
# the user has already filled its billing_infos, thus we can
|
||||||
kwargs["et_request"]["PBX_IDENTIFIANT"] = settings.SITH_EBOUTIC_PBX_IDENTIFIANT
|
# get it without expecting an error
|
||||||
kwargs["et_request"]["PBX_TOTAL"] = int(kwargs["basket"].get_total() * 100)
|
data = kwargs["basket"].get_e_transaction_data()
|
||||||
kwargs["et_request"][
|
data = {"data": [{"key": key, "value": val} for key, val in data]}
|
||||||
"PBX_DEVISE"
|
kwargs["billing_infos"] = json.dumps(data)
|
||||||
] = 978 # This is Euro. ET support only this value anyway
|
kwargs["billing_form"] = BillingInfoForm(instance=default_billing_info)
|
||||||
kwargs["et_request"]["PBX_CMD"] = kwargs["basket"].id
|
|
||||||
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_HASH"] = "SHA512"
|
|
||||||
kwargs["et_request"]["PBX_TYPEPAIEMENT"] = "CARTE"
|
|
||||||
kwargs["et_request"]["PBX_TYPECARTE"] = "CB"
|
|
||||||
kwargs["et_request"]["PBX_TIME"] = str(
|
|
||||||
datetime.now().replace(microsecond=0).isoformat("T")
|
|
||||||
)
|
|
||||||
kwargs["et_request"]["PBX_HMAC"] = (
|
|
||||||
hmac.new(
|
|
||||||
settings.SITH_EBOUTIC_HMAC_KEY,
|
|
||||||
bytes(
|
|
||||||
"&".join(
|
|
||||||
["%s=%s" % (k, v) for k, v in kwargs["et_request"].items()]
|
|
||||||
),
|
|
||||||
"utf-8",
|
|
||||||
),
|
|
||||||
"sha512",
|
|
||||||
)
|
|
||||||
.hexdigest()
|
|
||||||
.upper()
|
|
||||||
)
|
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_GET
|
||||||
|
def e_transaction_data(request):
|
||||||
|
basket = Basket.from_session(request.session)
|
||||||
|
if basket is None:
|
||||||
|
return HttpResponse(status=404, content=json.dumps({"data": []}))
|
||||||
|
data = basket.get_e_transaction_data()
|
||||||
|
data = {"data": [{"key": key, "value": val} for key, val in data]}
|
||||||
|
return HttpResponse(status=200, content=json.dumps(data))
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def pay_with_sith(request):
|
def pay_with_sith(request):
|
||||||
@ -171,21 +161,11 @@ def pay_with_sith(request):
|
|||||||
res = redirect("eboutic:payment_result", "failure")
|
res = redirect("eboutic:payment_result", "failure")
|
||||||
else:
|
else:
|
||||||
eboutic = Counter.objects.filter(type="EBOUTIC").first()
|
eboutic = Counter.objects.filter(type="EBOUTIC").first()
|
||||||
|
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
for it in basket.items.all():
|
for sale in sales:
|
||||||
product = eboutic.products.get(id=it.product_id)
|
sale.save()
|
||||||
Selling(
|
|
||||||
label=it.product_name,
|
|
||||||
counter=eboutic,
|
|
||||||
club=product.club,
|
|
||||||
product=product,
|
|
||||||
seller=c.user,
|
|
||||||
customer=c,
|
|
||||||
unit_price=it.product_unit_price,
|
|
||||||
quantity=it.quantity,
|
|
||||||
payment_method="SITH_ACCOUNT",
|
|
||||||
).save()
|
|
||||||
basket.delete()
|
basket.delete()
|
||||||
request.session.pop("basket_id", None)
|
request.session.pop("basket_id", None)
|
||||||
res = redirect("eboutic:payment_result", "success")
|
res = redirect("eboutic:payment_result", "success")
|
||||||
@ -205,12 +185,8 @@ class EtransactionAutoAnswer(View):
|
|||||||
# Response documentation http://www1.paybox.com/espace-integrateur-documentation
|
# Response documentation http://www1.paybox.com/espace-integrateur-documentation
|
||||||
# /la-solution-paybox-system/gestion-de-la-reponse/
|
# /la-solution-paybox-system/gestion-de-la-reponse/
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
if (
|
required = {"Amount", "BasketID", "Error", "Sig"}
|
||||||
not "Amount" in request.GET.keys()
|
if not required.issubset(set(request.GET.keys())):
|
||||||
or not "BasketID" in request.GET.keys()
|
|
||||||
or not "Error" in request.GET.keys()
|
|
||||||
or not "Sig" in request.GET.keys()
|
|
||||||
):
|
|
||||||
return HttpResponse("Bad arguments", status=400)
|
return HttpResponse("Bad arguments", status=400)
|
||||||
key = crypto.load_publickey(crypto.FILETYPE_PEM, settings.SITH_EBOUTIC_PUB_KEY)
|
key = crypto.load_publickey(crypto.FILETYPE_PEM, settings.SITH_EBOUTIC_PUB_KEY)
|
||||||
cert = crypto.X509()
|
cert = crypto.X509()
|
||||||
|
@ -1,3 +1,37 @@
|
|||||||
|
from ajax_select import make_ajax_form
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
# Register your models here.
|
from election.models import Election, Role, ElectionList, Candidature
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Election)
|
||||||
|
class ElectionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"title",
|
||||||
|
"is_candidature_active",
|
||||||
|
"is_vote_active",
|
||||||
|
"is_vote_finished",
|
||||||
|
"archived",
|
||||||
|
)
|
||||||
|
form = make_ajax_form(Election, {"voters": "users"})
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Role)
|
||||||
|
class RoleAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("election", "title", "max_choice")
|
||||||
|
search_fields = ("election__title", "title")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ElectionList)
|
||||||
|
class ElectionListAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("election", "title")
|
||||||
|
search_fields = ("election__title", "title")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Candidature)
|
||||||
|
class CandidatureAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "role", "election_list")
|
||||||
|
form = make_ajax_form(Candidature, {"user": "users"})
|
||||||
|
|
||||||
|
|
||||||
|
# Votes must stay fully anonymous, so no ModelAdmin for Vote model
|
||||||
|
@ -21,13 +21,29 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
from ajax_select import make_ajax_form
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from launderette.models import *
|
from launderette.models import *
|
||||||
|
|
||||||
# Register your models here.
|
|
||||||
admin.site.register(Launderette)
|
@admin.register(Launderette)
|
||||||
admin.site.register(Machine)
|
class LaunderetteAdmin(admin.ModelAdmin):
|
||||||
admin.site.register(Token)
|
list_display = ("name", "counter")
|
||||||
admin.site.register(Slot)
|
|
||||||
|
|
||||||
|
@admin.register(Machine)
|
||||||
|
class MachineAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "launderette", "type", "is_working")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Token)
|
||||||
|
class TokenAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "launderette", "type", "user")
|
||||||
|
form = make_ajax_form(Token, {"user": "users"})
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Slot)
|
||||||
|
class SlotAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("machine", "user", "start_date")
|
||||||
|
form = make_ajax_form(Slot, {"user": "users"})
|
||||||
|
@ -41,7 +41,8 @@ from club.models import Club
|
|||||||
from core.views import CanViewMixin, CanEditMixin, CanEditPropMixin, CanCreateMixin
|
from core.views import CanViewMixin, CanEditMixin, CanEditPropMixin, CanCreateMixin
|
||||||
from launderette.models import Launderette, Token, Machine, Slot
|
from launderette.models import Launderette, Token, Machine, Slot
|
||||||
from counter.models import Counter, Customer, Selling
|
from counter.models import Counter, Customer, Selling
|
||||||
from counter.views import GetUserForm
|
from counter.forms import GetUserForm
|
||||||
|
|
||||||
|
|
||||||
# For users
|
# For users
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -21,7 +21,39 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
from ajax_select import make_ajax_form
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from haystack.admin import SearchModelAdmin
|
||||||
|
|
||||||
# Register your models here.
|
from pedagogy.models import UV, UVComment, UVCommentReport
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UV)
|
||||||
|
class UVAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("code", "title", "credit_type", "credits", "department")
|
||||||
|
search_fields = ("code", "title", "department")
|
||||||
|
form = make_ajax_form(UV, {"author": "users"})
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UVComment)
|
||||||
|
class UVCommentAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("author", "uv", "grade_global", "publish_date")
|
||||||
|
search_fields = (
|
||||||
|
"author__username",
|
||||||
|
"author__first_name",
|
||||||
|
"author__last_name",
|
||||||
|
"uv__code",
|
||||||
|
)
|
||||||
|
form = make_ajax_form(UVComment, {"author": "users"})
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UVCommentReport)
|
||||||
|
class UVCommentReportAdmin(SearchModelAdmin):
|
||||||
|
list_display = ("reporter", "uv")
|
||||||
|
search_fields = (
|
||||||
|
"reporter__username",
|
||||||
|
"reporter__first_name",
|
||||||
|
"reporter__last_name",
|
||||||
|
"comment__uv__code",
|
||||||
|
)
|
||||||
|
form = make_ajax_form(UVCommentReport, {"reporter": "users"})
|
||||||
|
@ -325,6 +325,10 @@ class UVCommentReport(models.Model):
|
|||||||
)
|
)
|
||||||
reason = models.TextField(_("reason"))
|
reason = models.TextField(_("reason"))
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def uv(self):
|
||||||
|
return self.comment.uv
|
||||||
|
|
||||||
def is_owned_by(self, user):
|
def is_owned_by(self, user):
|
||||||
"""
|
"""
|
||||||
Can be created by a pedagogy admin, a superuser or a subscriber
|
Can be created by a pedagogy admin, a superuser or a subscriber
|
||||||
|
2287
poetry.lock
generated
2287
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -43,9 +43,10 @@ psycopg2-binary = "2.9.3"
|
|||||||
sentry-sdk = "^1.4.3"
|
sentry-sdk = "^1.4.3"
|
||||||
pygraphviz = "^1.9"
|
pygraphviz = "^1.9"
|
||||||
Jinja2 = "^3.1"
|
Jinja2 = "^3.1"
|
||||||
|
django-countries = "^7.4.2"
|
||||||
|
dict2xml = "^1.7.2"
|
||||||
|
|
||||||
# Extra optional dependencies
|
# Extra optional dependencies
|
||||||
mysqlclient = { version = "^2.0.3", optional = true }
|
|
||||||
coverage = {version = "^5.5", optional = true}
|
coverage = {version = "^5.5", optional = true}
|
||||||
|
|
||||||
# Docs extra dependencies
|
# Docs extra dependencies
|
||||||
@ -55,7 +56,6 @@ sphinx-copybutton = {version = "^0.4.0", optional = true}
|
|||||||
|
|
||||||
[tool.poetry.extras]
|
[tool.poetry.extras]
|
||||||
testing = ["coverage"]
|
testing = ["coverage"]
|
||||||
migration = ["mysqlclient"]
|
|
||||||
docs = ["Sphinx", "sphinx-rtd-theme", "sphinx-copybutton"]
|
docs = ["Sphinx", "sphinx-rtd-theme", "sphinx-copybutton"]
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
|
@ -682,13 +682,13 @@ if SENTRY_DSN:
|
|||||||
SITH_FRONT_DEP_VERSIONS = {
|
SITH_FRONT_DEP_VERSIONS = {
|
||||||
"https://github.com/chartjs/Chart.js/": "2.6.0",
|
"https://github.com/chartjs/Chart.js/": "2.6.0",
|
||||||
"https://github.com/xdan/datetimepicker/": "2.5.21",
|
"https://github.com/xdan/datetimepicker/": "2.5.21",
|
||||||
"https://github.com/Ionaru/easy-markdown-editor/": "2.7.0",
|
"https://github.com/Ionaru/easy-markdown-editor/": "2.18.0",
|
||||||
"https://github.com/FortAwesome/Font-Awesome/": "4.7.0",
|
"https://github.com/FortAwesome/Font-Awesome/": "4.7.0",
|
||||||
"https://github.com/jquery/jquery/": "3.1.0",
|
"https://github.com/jquery/jquery/": "3.6.2",
|
||||||
"https://github.com/sethmcl/jquery-ui/": "1.11.1",
|
"https://github.com/sethmcl/jquery-ui/": "1.11.1",
|
||||||
"https://github.com/viralpatel/jquery.shorten/": "",
|
"https://github.com/viralpatel/jquery.shorten/": "",
|
||||||
"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",
|
"https://github.com/alpinejs/alpine": "3.10.5",
|
||||||
}
|
}
|
||||||
|
@ -21,20 +21,25 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
from ajax_select import make_ajax_form
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
from haystack.admin import SearchModelAdmin
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionAdmin(SearchModelAdmin):
|
@admin.register(Subscription)
|
||||||
search_fields = [
|
class SubscriptionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"member",
|
||||||
|
"subscription_type",
|
||||||
|
"subscription_start",
|
||||||
|
"subscription_end",
|
||||||
|
"location",
|
||||||
|
)
|
||||||
|
search_fields = (
|
||||||
"member__username",
|
"member__username",
|
||||||
"subscription_start",
|
"subscription_start",
|
||||||
"subscription_end",
|
"subscription_end",
|
||||||
"subscription_type",
|
"subscription_type",
|
||||||
]
|
)
|
||||||
|
form = make_ajax_form(Subscription, {"member": "users"})
|
||||||
|
|
||||||
admin.site.register(Subscription, SubscriptionAdmin)
|
|
||||||
|
@ -97,19 +97,12 @@ class Subscription(models.Model):
|
|||||||
# TODO see SubscriptionForm's clean method
|
# TODO see SubscriptionForm's clean method
|
||||||
raise ValidationError(_("Subscription error"))
|
raise ValidationError(_("Subscription error"))
|
||||||
|
|
||||||
def save(self):
|
def save(self, *args, **kwargs):
|
||||||
super(Subscription, self).save()
|
super(Subscription, self).save()
|
||||||
from counter.models import Customer
|
from counter.models import Customer
|
||||||
|
|
||||||
if not Customer.objects.filter(user=self.member).exists():
|
if not Customer.objects.filter(user=self.member).exists():
|
||||||
last_id = (
|
Customer.new_for_user(self.member).save()
|
||||||
Customer.objects.count() + 1504
|
|
||||||
) # Number to keep a continuity with the old site
|
|
||||||
Customer(
|
|
||||||
user=self.member,
|
|
||||||
account_id=Customer.generate_account_id(last_id + 1),
|
|
||||||
amount=0,
|
|
||||||
).save()
|
|
||||||
form = PasswordResetForm({"email": self.member.email})
|
form = PasswordResetForm({"email": self.member.email})
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save(
|
form.save(
|
||||||
@ -177,69 +170,3 @@ class Subscription(models.Model):
|
|||||||
self.subscription_start <= date.today()
|
self.subscription_start <= date.today()
|
||||||
and date.today() <= self.subscription_end
|
and date.today() <= self.subscription_end
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def guy_test(date, duration=4):
|
|
||||||
print(
|
|
||||||
str(date)
|
|
||||||
+ " - "
|
|
||||||
+ str(duration)
|
|
||||||
+ " -> "
|
|
||||||
+ str(Subscription.compute_start(date, duration))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def bibou_test(duration, date=date.today()):
|
|
||||||
print(
|
|
||||||
str(date)
|
|
||||||
+ " - "
|
|
||||||
+ str(duration)
|
|
||||||
+ " -> "
|
|
||||||
+ str(
|
|
||||||
Subscription.compute_end(
|
|
||||||
duration, Subscription.compute_start(date, duration)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def guy():
|
|
||||||
guy_test(date(2015, 7, 11))
|
|
||||||
guy_test(date(2015, 8, 11))
|
|
||||||
guy_test(date(2015, 2, 17))
|
|
||||||
guy_test(date(2015, 3, 17))
|
|
||||||
guy_test(date(2015, 1, 11))
|
|
||||||
guy_test(date(2015, 2, 11))
|
|
||||||
guy_test(date(2015, 8, 17))
|
|
||||||
guy_test(date(2015, 9, 17))
|
|
||||||
print("=" * 80)
|
|
||||||
guy_test(date(2015, 7, 11), 1)
|
|
||||||
guy_test(date(2015, 8, 11), 2)
|
|
||||||
guy_test(date(2015, 2, 17), 3)
|
|
||||||
guy_test(date(2015, 3, 17), 4)
|
|
||||||
guy_test(date(2015, 1, 11), 1)
|
|
||||||
guy_test(date(2015, 2, 11), 2)
|
|
||||||
guy_test(date(2015, 8, 17), 3)
|
|
||||||
guy_test(date(2015, 9, 17), 4)
|
|
||||||
print("=" * 80)
|
|
||||||
bibou_test(1, date(2015, 2, 18))
|
|
||||||
bibou_test(2, date(2015, 2, 18))
|
|
||||||
bibou_test(3, date(2015, 2, 18))
|
|
||||||
bibou_test(4, date(2015, 2, 18))
|
|
||||||
bibou_test(1, date(2015, 9, 18))
|
|
||||||
bibou_test(2, date(2015, 9, 18))
|
|
||||||
bibou_test(3, date(2015, 9, 18))
|
|
||||||
bibou_test(4, date(2015, 9, 18))
|
|
||||||
print("=" * 80)
|
|
||||||
bibou_test(1, date(2000, 2, 29))
|
|
||||||
bibou_test(2, date(2000, 2, 29))
|
|
||||||
bibou_test(1, date(2000, 5, 31))
|
|
||||||
bibou_test(1, date(2000, 7, 31))
|
|
||||||
bibou_test(1)
|
|
||||||
bibou_test(2)
|
|
||||||
bibou_test(3)
|
|
||||||
bibou_test(4)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
guy()
|
|
||||||
|
Loading…
Reference in New Issue
Block a user