Commit 28dd803c by François C.

Merge branch 'dev_cooperatic' into 'dev_principale'

Cooperatic devs

See merge request !23
parents dfda5d22 8452c697
Pipeline #1026 passed with stage
in 21 seconds
...@@ -10,6 +10,7 @@ outils/settings_secret.py ...@@ -10,6 +10,7 @@ outils/settings_secret.py
outils/config.py outils/config.py
outils/texts/* outils/texts/*
outils/js_errors.log outils/js_errors.log
outils/scripts/scripts_settings.py
db.sqlite3 db.sqlite3
*/max_timeslot_carts.txt */max_timeslot_carts.txt
.gitlab-ci.yml .gitlab-ci.yml
......
"""Company specific data values.""" """Company specific data values."""
MAG_NAME = 'Cleme'
OFFICE_NAME = ''
MAX_BEGIN_HOUR = '19:00'
COMPANY_NAME = 'La Cagette'
WELCOME_ENTRANCE_MSG = 'Bienvenue à La Cagette !'
WELCOME_MAIL_SUBJECT = 'Dernière étape de votre inscription à la Cagette.'
WELCOME_MAIL_TEMPLATE = 'members/bienvenue.html'
EMAIL_DOMAIN = 'lacagette-coop.fr'
OPEN_ON_SUNDAY = True OPEN_ON_SUNDAY = True
FLV_CSV_NB = 4
CAP_JOURNAL_ID = 9 CAP_JOURNAL_ID = 9
CAP_APPELE_NON_VERSE_ACCOUNT_ID = 529 CAP_APPELE_NON_VERSE_ACCOUNT_ID = 529
CAP_APPELE_VERSE_ACCOUNT_ID = 8 CAP_APPELE_VERSE_ACCOUNT_ID = 8
CAP_INVOICE_LINE_ACCOUNT_ID = 8 CAP_INVOICE_LINE_ACCOUNT_ID = 8
FUNDRAISING_CAT_ID = 1 FUNDRAISING_CAT_ID = 1
UNITE_UOM_ID = 1 UNITE_UOM_ID = 1
PARTS_A_PRODUCT_ID = 1008 PARTS_A_PRODUCT_ID = 1008
PARTS_A_PRICE_UNIT = 10.0 PARTS_A_PRICE_UNIT = 10.0
COOP_BARCODE_RULE_ID = 11 COOP_BARCODE_RULE_ID = 11
CHECK_PAYMENT_ID = 8 CHECK_PAYMENT_ID = 8
CASH_PAYMENT_ID = 18 CASH_PAYMENT_ID = 18
STOCK_LOC_ID = 12
STOCK_LOC_ID = 12
CATEG_FRUIT = 151 CATEG_FRUIT = 151
CATEG_LEGUME = 152 CATEG_LEGUME = 152
FLV_CSV_NB = 4
COEFF_MAG_ID = 1 COEFF_MAG_ID = 1
...@@ -50,7 +55,7 @@ EM_URL = '' ...@@ -50,7 +55,7 @@ EM_URL = ''
RECEPTION_MERGE_ORDERS_PSWD = 'jpsrcp' RECEPTION_MERGE_ORDERS_PSWD = 'jpsrcp'
DAV_PATH = '/data/dav/cagette' DAV_PATH = '/shared_dir/dav/'
TOOLS_SERVER = 'https://outils.lacagette-coop.fr' TOOLS_SERVER = 'https://outils.lacagette-coop.fr'
...@@ -78,6 +83,8 @@ SHOP_CATEGORIES = {'epicerie': {'id': 75, 'label': 'Epicerie'}, ...@@ -78,6 +83,8 @@ SHOP_CATEGORIES = {'epicerie': {'id': 75, 'label': 'Epicerie'},
'parfumerie': {'id': 133, 'label': 'Parfumerie'}} 'parfumerie': {'id': 133, 'label': 'Parfumerie'}}
SHOP_EXTRA_MENUS = ['shop/planning_livraison_pains.html', 'shop/combien_ca_pese.html'] SHOP_EXTRA_MENUS = ['shop/planning_livraison_pains.html', 'shop/combien_ca_pese.html']
SHOP_SURVEY_LINK = 'https://docs.google.com/forms/d/e/1FAIpQLSczl0mMRwx3s9LbUSPYwwFTiiRa6agx7YkQM9cL41eiQnXNUw/viewform' SHOP_SURVEY_LINK = 'https://docs.google.com/forms/d/e/1FAIpQLSczl0mMRwx3s9LbUSPYwwFTiiRa6agx7YkQM9cL41eiQnXNUw/viewform'
SHOP_LIMIT_PRODUCTS = ['relatively_available', 'no_shelf']
EXCLUDE_SHOP_CATEGORIES = [108] EXCLUDE_SHOP_CATEGORIES = [108]
PROMOTE_SHELFS_IDS = [68] PROMOTE_SHELFS_IDS = [68]
DISCOUNT_SHELFS_IDS = [74] DISCOUNT_SHELFS_IDS = [74]
...@@ -85,13 +92,18 @@ FL_SHELFS = [16, 17, 18] ...@@ -85,13 +92,18 @@ FL_SHELFS = [16, 17, 18]
VRAC_SHELFS = [20, 38] VRAC_SHELFS = [20, 38]
SHIFT_EXCHANGE_DAYS_TO_HIDE = '' SHIFT_EXCHANGE_DAYS_TO_HIDE = ''
SHIFT_INFO = """A la cagette, un service est une plage de trois heures un jour en particulier, par exemple : le mardi 25/09/2018 à 13h15.
<br />A l'inverse, un créneau est une plage de trois heures régulière, par exemple, tous les mardi de semaine A à 13h15."""
PB_INSTRUCTIONS = """Si j'ai un problème, que je suis désinscrit, que je veux changer de créneaux ou quoi que ce soit, merci de vous rendre dans la section \"J'ai un problème\" sur le site web de <a href=\"https://lacagette-coop.fr/?MonEspaceMembre\">La Cagette</a>"""
ENTRANCE_COME_FOR_SHOPING_MSG = "Hey coucou toi ! Cet été nous sommes plus de <strong>1000 acheteur·euses</strong> pour seulement <strong>300 coopérateur·rice·s</strong> en service. <br />Tu fais tes courses à La Cagette cet été ?<br/> Inscris-toi sur ton espace membre !" ENTRANCE_COME_FOR_SHOPING_MSG = "Hey coucou toi ! Cet été nous sommes plus de <strong>1000 acheteur·euses</strong> pour seulement <strong>300 coopérateur·rice·s</strong> en service. <br />Tu fais tes courses à La Cagette cet été ?<br/> Inscris-toi sur ton espace membre !"
# Members space / shifts # Members space / shifts
UNSUBSCRIBED_MSG = 'Vous êtes désincrit·e, merci de remplir <a href="https://docs.google.com/forms/d/e/1FAIpQLSfPiC2PkSem9x_B5M7LKpoFNLDIz0k0V5I2W3Mra9AnqnQunw/viewform">ce formulaire</a> pour vous réinscrire sur un créneau.<br />Vous pouvez également contacter le Bureau des Membres en remplissant <a href="https://docs.google.com/forms/d/e/1FAIpQLSeZP0m5-EXPVJxEKJk6EjwSyZJtnbiGdYDuAeFI3ENsHAOikg/viewform">ce formulaire</a>' UNSUBSCRIBED_MSG = 'Vous êtes désincrit·e, merci de remplir <a href="https://docs.google.com/forms/d/e/1FAIpQLSfPiC2PkSem9x_B5M7LKpoFNLDIz0k0V5I2W3Mra9AnqnQunw/viewform">ce formulaire</a> pour vous réinscrire sur un créneau.<br />Vous pouvez également contacter le Bureau des Membres en remplissant <a href="https://docs.google.com/forms/d/e/1FAIpQLSeZP0m5-EXPVJxEKJk6EjwSyZJtnbiGdYDuAeFI3ENsHAOikg/viewform">ce formulaire</a>'
CONFIRME_PRESENT_BTN = 'Clique ici pour valider ta présence'
RECEPTION_PB = "Ici, vous pouvez signaler toute anomalie lors d'une réception, les produits non commandés, cassés ou pourris. \ RECEPTION_PB = "Ici, vous pouvez signaler toute anomalie lors d'une réception, les produits non commandés, cassés ou pourris. \
Merci d'indiquer un maximum d'informations, le nom du produit et son code barre. \ Merci d'indiquer un maximum d'informations, le nom du produit et son code barre. \
Dans le cas de produits déteriorés, merci d'envoyer une photo avec votre téléphone à [Adresse_email]" Dans le cas de produits déteriorés, merci d'envoyer une photo avec votre téléphone à [Adresse_email]"
...@@ -21,6 +21,16 @@ var to_fill_box = $('#to_fill'), ...@@ -21,6 +21,16 @@ var to_fill_box = $('#to_fill'),
problem_delete = $('#problem_delete'), problem_delete = $('#problem_delete'),
vform = $('#coop_validation_form'); vform = $('#coop_validation_form');
// date validation
Date.prototype.isValid = function () {
// If the date object is invalid it
// will return 'NaN' on getTime()
// and NaN is never equal to itself.
return this.getTime() === this.getTime();
};
sync.on('change', function (info) { sync.on('change', function (info) {
// handle change // handle change
if (info.direction == 'pull') { if (info.direction == 'pull') {
...@@ -248,17 +258,30 @@ function save_current_coop(callback) { ...@@ -248,17 +258,30 @@ function save_current_coop(callback) {
sex_error = false; sex_error = false;
if (/([0-9]{2})\/([0-9]{2})\/([0-9]{4})/.exec(birthdate)) { if (/([0-9]{2})\/([0-9]{2})\/([0-9]{4})/.exec(birthdate)) {
var jj = RegExp.$1, try{
var jj = RegExp.$1,
mm = RegExp.$2, mm = RegExp.$2,
aaaa = RegExp.$3; aaaa = RegExp.$3;
if (jj > 31 || mm > 12 || aaaa < 1900 || aaaa > 2018) { let tmp_date = aaaa + "-" + mm + "-" + jj;
// try to create a date object
date_test = new Date(tmp_date);
// if date is invalid a correction is apply in date object. Check it
// january start at 0, so we add + 1 for the month
if ((date_test.getDate() !== parseInt(jj)) || ((date_test.getMonth()+1) !== parseInt(mm)) || (date_test.getFullYear() !== parseInt(aaaa)) || !date_test.isValid())
{
birthdate_error = true;
}
}catch(Exception){
birthdate_error = true; birthdate_error = true;
} }
} else { } else {
birthdate_error = true; birthdate_error = true;
} }
let street2_input = form.find('[name="street2"]'), let street2_input = form.find('[name="street2"]'),
phone_input = form.find('[name="phone"]'); phone_input = form.find('[name="phone"]');
......
...@@ -23,7 +23,8 @@ def index(request): ...@@ -23,7 +23,8 @@ def index(request):
'WELCOME_ENTRANCE_MSG': getattr(settings, 'WELCOME_ENTRANCE_MSG', 'Bienvenue !'), 'WELCOME_ENTRANCE_MSG': getattr(settings, 'WELCOME_ENTRANCE_MSG', 'Bienvenue !'),
'WELCOME_SUBTITLE_ENTRANCE_MSG': getattr(settings, 'WELCOME_SUBTITLE_ENTRANCE_MSG', ''), 'WELCOME_SUBTITLE_ENTRANCE_MSG': getattr(settings, 'WELCOME_SUBTITLE_ENTRANCE_MSG', ''),
'ENTRANCE_SHOPPING_BTN': getattr(settings, 'ENTRANCE_SHOPPING_BTN', 'Je viens faire mes courses'), 'ENTRANCE_SHOPPING_BTN': getattr(settings, 'ENTRANCE_SHOPPING_BTN', 'Je viens faire mes courses'),
'ENTRANCE_SERVICE_BTN': getattr(settings, 'ENTRANCE_SERVICE_BTN', 'Je viens faire mon service') 'ENTRANCE_SERVICE_BTN': getattr(settings, 'ENTRANCE_SERVICE_BTN', 'Je viens faire mon service'),
'CONFIRME_PRESENT_BTN' : getattr(settings, 'CONFIRME_PRESENT_BTN', 'Présent.e')
} }
for_shoping_msg = getattr(settings, 'ENTRANCE_COME_FOR_SHOPING_MSG', '') for_shoping_msg = getattr(settings, 'ENTRANCE_COME_FOR_SHOPING_MSG', '')
......
# coding: utf-8
import sys, getopt, os
sys.path.append(os.path.abspath('../..'))
from outils.common import OdooAPI
from outils.config import COOP_BARCODE_RULE_ID
from openpyxl import Workbook
from openpyxl import load_workbook
from openpyxl.styles import Alignment
import datetime
# from django.conf import settings
from openpyxl.utils.exceptions import InvalidFileException
def main():
""" Pass file as script arg """
if len(sys.argv) < 2:
print("Il faut renseigner le chemin du fichier contenant les données des rattachés (format Excel).")
exit(2)
api = OdooAPI()
data_file = sys.argv[1]
try:
wb = load_workbook(data_file)
except FileNotFoundError:
print("Fichier introuvable.")
exit(2)
except InvalidFileException:
print("Le fichier fourni est invalide, il doit être au format Excel (.xlsx,.xlsm,.xltx,.xltm)")
exit(2)
while True:
try:
worksheet_number = input(f'Numéro de la feuille contenant les données (entre 0 et {len(wb.worksheets)-1}) : ')
worksheet_number = int(worksheet_number)
ws = wb.worksheets[worksheet_number]
except ValueError:
print("Veuillez rentrer un numéro entier.")
continue
except IndexError:
print("Cette feuille n'existe pas.")
continue
else:
break
columns_valid = input("""
Les colonnes doivent être les suivantes :
A: active*
B: barode_base*
C: name*
D: (ignorée)
E: date_inclusion
F: email
G: birthdate
H: mobile
I: phone
J: street
K: street2
L: zip
M: city
N: sex
O: (ignorée)
P: parent_member_id*
(* ces champs doivent être renseignés)
Vous confirmez ? (O/n) """)
while True:
if (columns_valid == 'n' or columns_valid == 'N'):
print("Veuillez formatter correctement le fichier avant de continuer !")
exit()
elif (columns_valid == 'o' or columns_valid == 'O' or columns_valid== ''):
break
else:
columns_valid = input("Vous confirmez ? (O/n) ")
continue
users = []
has_error = False
for row in ws.iter_rows(min_row=2, values_only=True):
# active
if row[0] is None or row[0] is False or row[0] == "=FALSE()" or row[0] == "=false":
active = False
else:
active = True
# If line is not empty (mandatory field check)
if row[2] is not None:
user = {
"is_member": False,
"is_associated_people": True,
"active": active,
"barcode_rule_id": COOP_BARCODE_RULE_ID,
"name": row[2],
"parent_id": int(row[15]), # for development, override with local existing member id
}
if row[1] is not None and row[1] != "NON":
try:
user["barcode_base"] = int(row[1])
except Exception:
print(f"[Mauvais format du champ 'barcode_base' pour l'utilisateur '{row[2]}' (Attendu : nombre entier)")
has_error = True
if row[4] is not None:
user["comment"] = f"Date d'inclusion : {row[4].date().strftime('%d/%m/%Y')}"
if row[5] is not None:
user["email"] = row[5]
if row[6] is not None:
user["birthdate"] = str(row[6].date())
if row[7] is not None:
try:
mobile = str(row[7]).replace(" ", "")
if mobile[0] != '+' and mobile[0] != '0':
mobile = '0' + mobile
user["mobile"] = mobile
except Exception:
print(f"[Mauvais format du champ 'mobile' pour l'utilisateur '{row[2]}'")
has_error = True
if row[8] is not None:
try:
phone = str(row[8]).replace(" ", "")
if phone[0] != '+' and phone[0] != '0':
phone = '0' + phone
user["phone"] = phone
except Exception:
print(f"[Mauvais format du champ 'phone' pour l'utilisateur '{row[2]}'")
has_error = True
if row[9] is not None:
user["street"] = row[9]
if row[10] is not None:
user["street2"] = row[10]
if row[11] is not None:
try:
zipcode = str(int(row[11]))
if len(zipcode) == 4:
zipcode = '0' + zipcode
user["zip"] = zipcode
except Exception:
print(f"[Mauvais format du champ 'zipcode' pour l'utilisateur '{row[2]}'")
has_error = True
if row[12] is not None:
user["city"] = row[12]
if row[13] == "Femme":
user["sex"] = "f"
elif row[13] == "Homme":
user["sex"] = "m"
users.append(user)
if has_error:
print("L'import a été interrompu, veuillez régler les erreurs.")
exit(2)
res = None
marshal_none_error = 'cannot marshal None unless allow_none is enabled'
for user in users:
try:
res = api.create('res.partner', user)
if res:
print(f"Rattaché.e importé.e avec succès : {user['name']} (id : {res})")
except Exception as e:
if not (marshal_none_error in str(e)):
print(f"Erreur lors de l'insertion de {user['name']}, vérifiez ses données dans le tableau ({str(e)})")
else:
pass
if __name__ == "__main__":
main()
\ No newline at end of file
SECRET_KEY = 'Mettre_plein_de_caracteres_aleatoires_iezezezeezezci'
ODOO = {
'url': 'http://127.0.0.1:8069'
'user': 'api',
'passwd': 'xxxxxxxxxxxx',
'db': 'bd_test',
}
COUCHDB = {
'url': 'http://127.0.0.1:5984',
'dbs': {
'member': 'coops',
'inventory': 'inventory',
'envelops': 'envelop',
'shop': 'shopping_carts'
}
}
...@@ -1336,6 +1336,15 @@ function openFAQ() { ...@@ -1336,6 +1336,15 @@ function openFAQ() {
function openErrorReport() { function openErrorReport() {
openModal($('#templates #modal_error_report').html(), saveErrorReport, 'Confirmer'); openModal($('#templates #modal_error_report').html(), saveErrorReport, 'Confirmer');
// listener for error report textarea
// this is necessary because default behavior is overwritten by the listener defined in jquery.pos.js;
$("#error_report").keypress(function(e) {
var key = e.keyCode;
if (key === 13) {
this.value += "\n";
}
});
var textarea = document.getElementById("error_report"); var textarea = document.getElementById("error_report");
textarea.value = (user_comments != undefined) ? user_comments : ""; textarea.value = (user_comments != undefined) ? user_comments : "";
...@@ -1481,7 +1490,7 @@ $(document).ready(function() { ...@@ -1481,7 +1490,7 @@ $(document).ready(function() {
reception_status = orders[Object.keys(orders)[0]].reception_status; reception_status = orders[Object.keys(orders)[0]].reception_status;
// Load user comments from local storage, get it from first order // Load user comments from local storage, get it from first order
user_comments = orders[Object.keys(orders)[0]].user_comments; user_comments = orders[Object.keys(orders)[0]].user_comments || "";
} }
// Fetch orders data // Fetch orders data
......
...@@ -27,32 +27,35 @@ class CagetteSales(models.Model): ...@@ -27,32 +27,35 @@ class CagetteSales(models.Model):
# Get payment lines # Get payment lines
cond = [['statement_id', 'in', statements]] cond = [['statement_id', 'in', statements]]
fields = ["partner_id", "amount", "journal_id", "create_date", "date"] fields = ["partner_id", "amount", "journal_id", "create_date", "date"]
payments = self.o_api.search_read('account.bank.statement.line', cond, fields, order="create_date ASC") payments = self.o_api.search_read('account.bank.statement.line', cond, fields, order="create_date ASC", limit=50000)
item = None item = None
try: try:
for payment in payments: for payment in payments:
if item is not None and item["partner_id"][0] == payment["partner_id"][0] and item["date"] == payment["date"]: # POS session can contain payments from another day (closing session on next morning, ...)
res[len(res)-1]["total_amount"] += round(float(payment["amount"]), 2) if payment["date"] >= date_from and payment["date"] <= date_to:
res[len(res)-1]["payments"].append({ # If the consecutive payment in the results is from the same partner on the same day, we consider it's the same basket
"amount": round(float(payment["amount"]), 2), if item is not None and item["partner_id"][0] == payment["partner_id"][0] and item["date"] == payment["date"]:
"journal_id": payment["journal_id"] res[len(res)-1]["total_amount"] += round(float(payment["amount"]), 2)
}) res[len(res)-1]["payments"].append({
else: "amount": round(float(payment["amount"]), 2),
item = { "journal_id": payment["journal_id"]
"partner_id": payment["partner_id"], })
"create_date": payment["create_date"], else:
"date": payment["date"], item = {
"total_amount": round(float(payment["amount"]), 2), "partner_id": payment["partner_id"],
"payments": [ "create_date": payment["create_date"],
{ "date": payment["date"],
"amount": round(float(payment["amount"]), 2), "total_amount": round(float(payment["amount"]), 2),
"journal_id": payment["journal_id"] "payments": [
} {
] "amount": round(float(payment["amount"]), 2),
} "journal_id": payment["journal_id"]
}
res.append(item) ]
}
res.append(item)
except Exception as e: except Exception as e:
pass pass
......
...@@ -20,4 +20,7 @@ ...@@ -20,4 +20,7 @@
.select_sales_date_input { .select_sales_date_input {
border-radius:5px; border-radius:5px;
} }
\ No newline at end of file .btn_export_movements {
margin-top: 10px;
}
\ No newline at end of file
...@@ -32,14 +32,13 @@ function display_orders(orders) { ...@@ -32,14 +32,13 @@ function display_orders(orders) {
if (orders_table) { if (orders_table) {
orders_table.destroy(); orders_table.destroy();
} }
console.log(orders);
orders_table = $('#orders_table').DataTable({ orders_table = $('#orders_table').DataTable({
data: orders, data: orders,
columns:[ columns:[
{ {
data:"create_date", data:"create_date",
title:"Date", title:"Date de vente",
width: "10%" width: "10%"
}, },
{ {
...@@ -61,7 +60,6 @@ function display_orders(orders) { ...@@ -61,7 +60,6 @@ function display_orders(orders) {
{ {
data:"payments", data:"payments",
title:"Paiements", title:"Paiements",
className:"dt-body-center",
orderable: false, orderable: false,
render: function (data) { render: function (data) {
let res = '<ul>'; let res = '<ul>';
...@@ -79,8 +77,15 @@ function display_orders(orders) { ...@@ -79,8 +77,15 @@ function display_orders(orders) {
"asc" "asc"
] ]
], ],
dom: 'rtip', buttons: [
iDisplayLength: 25, {
extend: 'excelHtml5',
text: 'Export en Excel',
className: 'btn--primary btn_export_movements'
}
],
dom: '<lr<t>ip><"clear"><B>',
iDisplayLength: 100,
language: {url : '/static/js/datatables/french.json'} language: {url : '/static/js/datatables/french.json'}
}); });
...@@ -188,8 +193,4 @@ $(document).ready(function() { ...@@ -188,8 +193,4 @@ $(document).ready(function() {
event.preventDefault(); event.preventDefault();
get_sales(); get_sales();
}); });
// $('#dates_selection_button').click(function() {
// get_sales();
// });
}); });
...@@ -161,7 +161,7 @@ ...@@ -161,7 +161,7 @@
<div class="validation_wrapper col-6"> <div class="validation_wrapper col-6">
<section id="service_validation" class="col-6 grid-6 has-gutter"> <section id="service_validation" class="col-6 grid-6 has-gutter">
<div class="col-2"></div> <div class="col-2"></div>
<a class="col-2 btn present">Présent.e</a> <a class="col-2 btn present">{{CONFIRME_PRESENT_BTN|safe}}</a>
<span class="loading2"><img width="75" src="/static/img/Pedro_luis_romani_ruiz.gif" alt="Chargement en cours...." /></span> <span class="loading2"><img width="75" src="/static/img/Pedro_luis_romani_ruiz.gif" alt="Chargement en cours...." /></span>
<div class="col-2"></div> <div class="col-2"></div>
</section> </section>
......
...@@ -11,7 +11,9 @@ ...@@ -11,7 +11,9 @@
<script type="text/javascript" src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}?v="></script> <script type="text/javascript" src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}?v="></script>
<script type="text/javascript" src="{% static 'js/datatables/jquery.dataTables.min.js' %}"></script> <script type="text/javascript" src="{% static 'js/datatables/jquery.dataTables.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/datatables/dataTables.plugins.js' %}"></script> <script type="text/javascript" src="{% static 'js/datatables/dataTables.plugins.js' %}"></script>
<script type="text/javascript" src="{% static 'js/datatables/datatables.buttons.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/datatables/buttons.html5.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/datatables/jszip.min.js' %}"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment