First commit
This commit is contained in:
commit
e77f8e8b58
62 changed files with 5511 additions and 0 deletions
3
MANIFEST.in
Normal file
3
MANIFEST.in
Normal file
|
@ -0,0 +1,3 @@
|
|||
graft lesa/static
|
||||
graft lesa/templates
|
||||
global-exclude *.pyc
|
16
TODO.md
Normal file
16
TODO.md
Normal file
|
@ -0,0 +1,16 @@
|
|||
## Список TODO
|
||||
|
||||
### Главная страница
|
||||
- Возможность выбрать между Google/Яндекс/OSM картами,
|
||||
по умолчанию - OSM, т.к. нет аналитики и рекламы.
|
||||
|
||||
### Язык разметки
|
||||
- Вставка карты (G/Я/OSM) в текст.
|
||||
- Вставка видео.
|
||||
- Загрузка картинок на сервер при oninput на input:file.
|
||||
|
||||
### Панель администратора
|
||||
- Нормальная сортировка: вместо функций JS запросы к БД,
|
||||
с ограничением на 50-70 строк, чтобы не заполнять этим ОЗУ.
|
||||
- Получение даты рождения всех детей для расчёта скидки на третьего.
|
||||
- Импортирование постов из ВКонтакте во вкладке "Посты".
|
4
lesa/__init__.py
Normal file
4
lesa/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
from .app import app
|
||||
|
||||
def create_app():
|
||||
return app
|
22
lesa/admin/__init__.py
Normal file
22
lesa/admin/__init__.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
from flask import request
|
||||
|
||||
access = ['']
|
||||
def check_token(xhr:bool=False):
|
||||
|
||||
cookie = request.cookies.get('lesa_admin', '0')
|
||||
result = (access[0] == cookie)
|
||||
|
||||
if xhr:
|
||||
data = request.get_json(force=True)
|
||||
if not data: return False
|
||||
|
||||
xhrtoken = data.get('token', '0')
|
||||
result = result and (access[0] == xhrtoken)
|
||||
|
||||
return result
|
||||
|
||||
from . import admin
|
||||
from . import formhandler
|
||||
from . import regdb
|
||||
from . import season
|
||||
from . import backup
|
151
lesa/admin/admin.py
Normal file
151
lesa/admin/admin.py
Normal file
|
@ -0,0 +1,151 @@
|
|||
import phonenumbers
|
||||
from phonenumbers import PhoneNumberFormat
|
||||
from datetime import date, datetime
|
||||
from flask import render_template, redirect
|
||||
from flask import make_response, request
|
||||
from typing import List, Optional
|
||||
|
||||
from ..app import app
|
||||
from ..consts import FONT
|
||||
from ..upload import post_dirs
|
||||
from ..upload import html_dirs
|
||||
from ..upload import listdir
|
||||
from ..models import Config, About
|
||||
from ..models import Post, Register
|
||||
from ..models import Shift, House
|
||||
from ..models import Parent, Child
|
||||
from ..models import AdminColumn
|
||||
from ..models import BeenLastYear
|
||||
|
||||
from . import check_token, access
|
||||
from .adminforms import LoginForm, PasswordForm
|
||||
from .adminforms import PostForm, SeasonForm, AboutForm
|
||||
|
||||
@app.route('/admin')
|
||||
def admin():
|
||||
|
||||
if check_token():
|
||||
return redirect('/admin/panel')
|
||||
|
||||
return render_template(
|
||||
'login.html',
|
||||
form=LoginForm(),
|
||||
font=FONT
|
||||
)
|
||||
|
||||
@app.route('/admin/logout', methods=['GET'])
|
||||
def logout():
|
||||
|
||||
if check_token():
|
||||
access[0] = ''
|
||||
|
||||
resp = make_response(redirect('/admin'))
|
||||
resp.delete_cookie('lesa_admin')
|
||||
return resp
|
||||
|
||||
@app.route('/admin/panel')
|
||||
def panel():
|
||||
|
||||
if not check_token():
|
||||
return redirect('/admin')
|
||||
|
||||
shifts: List[Shift] = Shift.query.order_by(Shift.id.asc()).all()
|
||||
shift = shifts[0] if len(shifts) > 0 else None
|
||||
shift_dt = date_timestamp(shift.begin) if shift else 0
|
||||
|
||||
config = Config.query.filter(Config.id == 0).first()
|
||||
about = About.query.filter(About.id == 0).first()
|
||||
|
||||
about_form = AboutForm()
|
||||
if about:
|
||||
about_form.text.data = about.text
|
||||
|
||||
photos_dir = post_dirs('about')[0]
|
||||
photos_html, thumbs_html = html_dirs('about')
|
||||
|
||||
return render_template(
|
||||
'admin.html',
|
||||
posts=Post.query.\
|
||||
order_by(Post.id.desc()).\
|
||||
all(),
|
||||
columns=AdminColumn.query.\
|
||||
order_by(AdminColumn.id.asc()).\
|
||||
all(),
|
||||
pform=PostForm(),
|
||||
about=about_form,
|
||||
sform=SeasonForm(),
|
||||
rform=Register.query.all(),
|
||||
aform=PasswordForm(),
|
||||
dates=shifts,
|
||||
housedb=House.query.all(),
|
||||
photos=listdir(photos_dir),
|
||||
photos_dir=photos_html,
|
||||
thumbs_dir=thumbs_html,
|
||||
shift=shift_dt,
|
||||
format_phone=format_phone,
|
||||
editor=lambda id:
|
||||
render_template(
|
||||
'editor.html',
|
||||
textarea_id=id
|
||||
),
|
||||
config=config,
|
||||
font=FONT
|
||||
)
|
||||
|
||||
@app.route('/admin/parents', methods=['POST'])
|
||||
def parentslst():
|
||||
|
||||
if not check_token(xhr=True):
|
||||
return redirect('/admin')
|
||||
|
||||
post = request.get_json(force=True)
|
||||
mid = post.get('mid')
|
||||
return render_template(
|
||||
'parents.html',
|
||||
format_phone=format_phone,
|
||||
data=Parent.query.\
|
||||
filter(Parent.mid == mid).\
|
||||
all()
|
||||
)
|
||||
|
||||
@app.route('/admin/children', methods=['POST'])
|
||||
def childrenlst():
|
||||
|
||||
if not check_token(xhr=True):
|
||||
return redirect('/admin')
|
||||
|
||||
shift: Shift = Shift.query.order_by(Shift.id.asc()).first()
|
||||
|
||||
post = request.get_json(force=True)
|
||||
mid = post.get('mid')
|
||||
return render_template(
|
||||
'children.html',
|
||||
shift=shift.begin,
|
||||
data=Child.query.\
|
||||
filter(Child.mid == mid).\
|
||||
all()
|
||||
)
|
||||
|
||||
@app.route('/admin/been_last_year', methods=['POST'])
|
||||
def been_last_year():
|
||||
|
||||
if not check_token(xhr=True):
|
||||
return redirect('/admin')
|
||||
|
||||
return render_template(
|
||||
'last_year.html',
|
||||
lst=BeenLastYear.query.all()
|
||||
)
|
||||
|
||||
def date_timestamp(dt: date) -> int:
|
||||
return datetime(
|
||||
year=dt.year,
|
||||
month=dt.month,
|
||||
day=dt.day
|
||||
).timestamp()
|
||||
|
||||
def format_phone(num:str) -> str:
|
||||
return phonenumbers.format_number(
|
||||
phonenumbers.parse(num),
|
||||
PhoneNumberFormat.INTERNATIONAL
|
||||
)
|
84
lesa/admin/adminforms.py
Normal file
84
lesa/admin/adminforms.py
Normal file
|
@ -0,0 +1,84 @@
|
|||
from datetime import datetime
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField
|
||||
from wtforms import PasswordField
|
||||
from wtforms import IntegerField, DateField
|
||||
from wtforms import MultipleFileField
|
||||
from wtforms import Form, FieldList, FormField
|
||||
from wtforms import validators
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
password = PasswordField(label='Пароль')
|
||||
|
||||
class PasswordForm(FlaskForm):
|
||||
oldpswd = PasswordField(
|
||||
label='Старый пароль',
|
||||
validators=[validators.DataRequired()]
|
||||
)
|
||||
newpswd = PasswordField(
|
||||
label='Новый пароль',
|
||||
validators=[validators.DataRequired()]
|
||||
)
|
||||
confirm = PasswordField(
|
||||
label='Повторите пароль',
|
||||
validators=[validators.DataRequired()]
|
||||
)
|
||||
|
||||
class PostForm(FlaskForm):
|
||||
title = StringField(
|
||||
label='Заголовок',
|
||||
validators=[validators.Optional()]
|
||||
)
|
||||
photos = MultipleFileField(label='Фотографии')
|
||||
body = TextAreaField(
|
||||
label='Текст', id='post-body',
|
||||
validators=[validators.DataRequired()]
|
||||
)
|
||||
|
||||
class AboutForm(FlaskForm):
|
||||
photos = MultipleFileField(label='Добавить фото')
|
||||
text = TextAreaField(label='Текст', id='about-text')
|
||||
|
||||
class ShiftForm(Form):
|
||||
begin = DateField(label='Начало смены')
|
||||
end = DateField(label='Конец смены')
|
||||
|
||||
class HouseForm(Form):
|
||||
name = StringField(label='Тип дома')
|
||||
price = IntegerField(label='Цена')
|
||||
|
||||
class SeasonForm(FlaskForm):
|
||||
|
||||
year = IntegerField(
|
||||
label='Год',
|
||||
validators=[validators.DataRequired()],
|
||||
default=datetime.now().year
|
||||
)
|
||||
|
||||
shifts = FieldList(FormField(ShiftForm), label='Смены', min_entries=1)
|
||||
houses = FieldList(FormField(HouseForm), label='Домики', min_entries=1)
|
||||
|
||||
meal = IntegerField(
|
||||
label='Стоимость питания',
|
||||
validators=[validators.DataRequired()]
|
||||
)
|
||||
child = IntegerField(
|
||||
label='Стоимость программы (ребёнок)',
|
||||
validators=[validators.DataRequired()],
|
||||
default=700
|
||||
)
|
||||
parent = IntegerField(
|
||||
label='Стоимость программы (взрослый)',
|
||||
validators=[validators.DataRequired()],
|
||||
default=500
|
||||
)
|
||||
|
||||
name = StringField(
|
||||
label='Место',
|
||||
validators=[validators.DataRequired()]
|
||||
)
|
||||
photos = MultipleFileField(label='Фотографии')
|
||||
embed = TextAreaField(
|
||||
label='Яндекс.Карты',
|
||||
validators=[validators.DataRequired()]
|
||||
)
|
260
lesa/admin/backup.py
Normal file
260
lesa/admin/backup.py
Normal file
|
@ -0,0 +1,260 @@
|
|||
import os
|
||||
import csv
|
||||
import enum
|
||||
import openpyxl
|
||||
from zipfile import ZipFile
|
||||
from datetime import date, timedelta
|
||||
from tempfile import NamedTemporaryFile
|
||||
from flask import redirect
|
||||
from flask import make_response
|
||||
from typing import Any
|
||||
|
||||
from ..app import app
|
||||
from ..upload import photos_dir
|
||||
from ..consts import XLSX_MIME
|
||||
from ..models import Post, Register
|
||||
from ..models import Parent, Child
|
||||
|
||||
from . import check_token
|
||||
|
||||
class CsvType(enum.IntEnum):
|
||||
main = 0
|
||||
parents = 1
|
||||
children = 2
|
||||
|
||||
@app.route('/admin/backup/reg/csv')
|
||||
def reg_csv_main():
|
||||
return reg_csv(CsvType.main)
|
||||
|
||||
@app.route('/admin/backup/regp/csv')
|
||||
def reg_csv_parents():
|
||||
return reg_csv(CsvType.parents)
|
||||
|
||||
@app.route('/admin/backup/regc/csv')
|
||||
def reg_csv_children():
|
||||
return reg_csv(CsvType.children)
|
||||
|
||||
@app.route('/admin/backup/reg/xls')
|
||||
def regdb_xlsx_main():
|
||||
return regdb_xlsx(add_main=True)
|
||||
|
||||
@app.route('/admin/backup/regp/xls')
|
||||
def regdb_xlsx_parents():
|
||||
return regdb_xlsx(add_parents=True)
|
||||
|
||||
@app.route('/admin/backup/regc/xls')
|
||||
def regdb_xlsx_children():
|
||||
return regdb_xlsx(add_children=True)
|
||||
|
||||
@app.route('/admin/backup/regdb/xls')
|
||||
def regdb_xlsx_full():
|
||||
return regdb_xlsx(
|
||||
add_main=True,
|
||||
add_parents=True,
|
||||
add_children=True
|
||||
)
|
||||
|
||||
def reg_csv(csv_type:CsvType) -> Any:
|
||||
|
||||
if not check_token():
|
||||
return redirect('/admin')
|
||||
|
||||
resp = ''
|
||||
with NamedTemporaryFile('wt+', newline='') as file:
|
||||
table = csv.writer(file, delimiter=';')
|
||||
|
||||
if csv_type == CsvType.main:
|
||||
|
||||
regdb = Register.query.all()
|
||||
|
||||
table.writerow((
|
||||
'№', 'ID', 'Семья', 'Смена',
|
||||
'Кол-во', 'Взрослые', 'Дети',
|
||||
'Дом', 'Друзья'
|
||||
))
|
||||
|
||||
for r in regdb:
|
||||
adults = int(r.count or 0) - int(r.children or 0)
|
||||
table.writerow((
|
||||
r.id, r.mid, r.family, r.dates,
|
||||
r.count, adults, r.children,
|
||||
r.house, r.friends
|
||||
))
|
||||
|
||||
if csv_type == CsvType.parents:
|
||||
|
||||
parents = Parent.query.all()
|
||||
|
||||
table.writerow((
|
||||
'№', 'ID', 'Фамилия', 'Имя', 'Отчество',
|
||||
'Телефон', 'Почта', 'Соц. сеть'
|
||||
))
|
||||
|
||||
for p in parents:
|
||||
table.writerow((
|
||||
p.id, p.mid, p.surname, p.firstname, p.midname,
|
||||
p.phone, p.email, p.social
|
||||
))
|
||||
|
||||
if csv_type == CsvType.children:
|
||||
|
||||
children = Child.query.all()
|
||||
|
||||
table.writerow((
|
||||
'№', 'ID', 'Фамилия', 'Имя',
|
||||
'Пол', 'Дата рождения', 'Возраст'
|
||||
))
|
||||
|
||||
for c in children:
|
||||
age:timedelta = date.today() - c.birthday
|
||||
table.writerow((
|
||||
c.id, c.mid, c.surname, c.firstname,
|
||||
c.gender, c.birthday, age.days // 365
|
||||
))
|
||||
del age
|
||||
|
||||
file.seek(0)
|
||||
resp = make_response(file.read())
|
||||
resp.content_type = 'text/csv'
|
||||
resp.content_length = file.tell()
|
||||
|
||||
return resp
|
||||
|
||||
def regdb_xlsx(
|
||||
add_main:bool=False,
|
||||
add_parents:bool=False,
|
||||
add_children:bool=False) -> Any:
|
||||
|
||||
if not check_token():
|
||||
return redirect('/admin')
|
||||
|
||||
regdb = Register.query.all()
|
||||
parents = Parent.query.all()
|
||||
children = Child.query.all()
|
||||
|
||||
table = openpyxl.Workbook()
|
||||
table.remove(table.active)
|
||||
|
||||
ws_main = []
|
||||
if add_main:
|
||||
ws_main = table.create_sheet('Основные данные')
|
||||
|
||||
ws_parents = []
|
||||
if add_parents:
|
||||
ws_parents = table.create_sheet('Родители')
|
||||
|
||||
ws_children = []
|
||||
if add_children:
|
||||
ws_children = table.create_sheet('Дети')
|
||||
|
||||
if add_main:
|
||||
ws_main.append((
|
||||
'№', 'ID', 'Семья', 'Смена',
|
||||
'Кол-во', 'Взрослые', 'Дети',
|
||||
'Дом', 'Друзья'
|
||||
))
|
||||
|
||||
if add_parents:
|
||||
ws_parents.append((
|
||||
'№', 'ID', 'Фамилия', 'Имя', 'Отчество',
|
||||
'Телефон', 'Почта', 'Соц. сеть'
|
||||
))
|
||||
|
||||
if add_children:
|
||||
ws_children.append((
|
||||
'№', 'ID', 'Фамилия', 'Имя',
|
||||
'Пол', 'Дата рождения', 'Возраст'
|
||||
))
|
||||
|
||||
for r in regdb:
|
||||
|
||||
if add_main:
|
||||
adults = int(r.count or 0) - int(r.children or 0)
|
||||
ws_main.append((
|
||||
r.id, r.mid, r.family, r.dates,
|
||||
r.count, adults, r.children,
|
||||
r.house, r.friends
|
||||
))
|
||||
|
||||
if add_parents:
|
||||
f_parents = [p for p in parents if p.mid == r.mid]
|
||||
for p in f_parents:
|
||||
ws_parents.append((
|
||||
p.id, p.mid, p.surname, p.firstname, p.midname,
|
||||
p.phone, p.email, p.social
|
||||
))
|
||||
|
||||
if add_children:
|
||||
f_children = [c for c in children if c.mid == r.mid]
|
||||
for c in f_children:
|
||||
age:timedelta = date.today() - c.birthday
|
||||
ws_children.append((
|
||||
c.id, c.mid, c.surname, c.firstname,
|
||||
c.gender, c.birthday, age.days // 365
|
||||
))
|
||||
del age
|
||||
|
||||
resp = ''
|
||||
with NamedTemporaryFile('wb+') as file:
|
||||
table.save(file.name)
|
||||
file.seek(0)
|
||||
resp = make_response(file.read())
|
||||
resp.content_type = XLSX_MIME
|
||||
resp.content_length = file.tell()
|
||||
return resp
|
||||
|
||||
@app.route('/admin/backup/posts/csv')
|
||||
def posts_csv():
|
||||
|
||||
if not check_token():
|
||||
return redirect('/admin')
|
||||
|
||||
resp = ''
|
||||
with NamedTemporaryFile('wt+', newline='') as file:
|
||||
table = csv.writer(file, delimiter=';')
|
||||
posts = Post.query.all()
|
||||
table.writerow((
|
||||
'№', 'Заголовок',
|
||||
'Unix-время', 'Время',
|
||||
'Содержимое'
|
||||
))
|
||||
|
||||
for p in posts:
|
||||
timestamp = p.dt.timestamp()
|
||||
table.writerow((
|
||||
p.id, p.title,
|
||||
timestamp, p.dt,
|
||||
p.body
|
||||
))
|
||||
|
||||
file.seek(0)
|
||||
resp = make_response(file.read())
|
||||
resp.content_type = 'text/csv'
|
||||
resp.content_length = file.tell()
|
||||
|
||||
return resp
|
||||
|
||||
@app.route('/admin/backup/images/zip')
|
||||
def images_zip():
|
||||
|
||||
resp = ''
|
||||
with NamedTemporaryFile('wb+') as file:
|
||||
|
||||
with ZipFile(file, 'w') as zip:
|
||||
for root, dirs, files in os.walk(photos_dir):
|
||||
for path in files:
|
||||
|
||||
# relative to photos_dir
|
||||
reldir = root.replace(photos_dir, '')
|
||||
relpath = os.path.join(reldir, path)
|
||||
# full
|
||||
fullpath = os.path.join(root, path)
|
||||
|
||||
zip.write(fullpath, relpath)
|
||||
|
||||
file.seek(0)
|
||||
resp = make_response(file.read())
|
||||
resp.content_type = 'application/zip'
|
||||
resp.content_length = file.tell()
|
||||
|
||||
return resp
|
177
lesa/admin/formhandler.py
Normal file
177
lesa/admin/formhandler.py
Normal file
|
@ -0,0 +1,177 @@
|
|||
import os
|
||||
import secrets
|
||||
from flask import redirect
|
||||
from flask import make_response, flash
|
||||
from flask import request
|
||||
from flask_wtf import FlaskForm
|
||||
from typing import Union
|
||||
|
||||
from ..app import app, bcrypt, db
|
||||
from ..reg import show_errors
|
||||
from ..upload import process_dirs
|
||||
from ..upload import save_imgs
|
||||
from ..upload import check_dirs
|
||||
from ..upload import remove_dirs
|
||||
from ..models import Config, Post, About
|
||||
|
||||
from . import check_token, access
|
||||
from .adminforms import LoginForm, PasswordForm
|
||||
from .adminforms import PostForm, AboutForm
|
||||
|
||||
@app.route('/admin/login', methods=['POST'])
|
||||
def login():
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
|
||||
# check
|
||||
pswd = form.data['password']
|
||||
adminpswd = Config.query.filter(Config.id == 0).first().adminpswd
|
||||
if bcrypt.check_password_hash(adminpswd, pswd):
|
||||
|
||||
# create access token
|
||||
token = secrets.token_urlsafe(16)
|
||||
access[0] = token
|
||||
|
||||
# set cookie
|
||||
resp = make_response(redirect('/admin/panel'))
|
||||
resp.set_cookie('lesa_admin', token)
|
||||
return resp
|
||||
|
||||
flash('Неверный пароль')
|
||||
return redirect('/admin')
|
||||
|
||||
@app.route('/admin/rmphoto', methods=['POST'])
|
||||
def rm_photo():
|
||||
|
||||
if not check_token(xhr=True):
|
||||
return redirect('/admin')
|
||||
|
||||
data = request.get_json(force=True)
|
||||
name = data.get('data')
|
||||
if not name: return ''
|
||||
|
||||
def remove(path, exists):
|
||||
if not exists: return
|
||||
file = os.path.join(path, name)
|
||||
try:
|
||||
os.remove(file)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
process_dirs(remove, 'about')
|
||||
return ''
|
||||
|
||||
@app.route('/admin/rmpost', methods=['POST'])
|
||||
def rm_post():
|
||||
|
||||
if not check_token(xhr=True):
|
||||
return redirect('/admin')
|
||||
|
||||
data = request.get_json(force=True)
|
||||
pid = data.get('data', 0)
|
||||
if not pid: return ''
|
||||
|
||||
pid = int(pid)
|
||||
db.session.\
|
||||
query(Post).filter(Post.id == pid).\
|
||||
delete()
|
||||
db.session.commit()
|
||||
|
||||
remove_dirs(pid)
|
||||
return ''
|
||||
|
||||
@app.route('/admin/post', methods=['POST'])
|
||||
def create_post():
|
||||
|
||||
if not check_token():
|
||||
return redirect('/admin')
|
||||
|
||||
form = PostForm()
|
||||
if form.validate_on_submit():
|
||||
|
||||
p = Post(
|
||||
title=form.title.data,
|
||||
body=form.body.data
|
||||
)
|
||||
db.session.add(p)
|
||||
db.session.commit()
|
||||
flash('I:Пост создан')
|
||||
|
||||
upload(form, p.id)
|
||||
|
||||
return redirect('/admin/panel#posts')
|
||||
|
||||
flash('F:' + show_errors(form))
|
||||
return redirect('/admin/panel#posts')
|
||||
|
||||
@app.route('/admin/about', methods=['POST'])
|
||||
def about_us():
|
||||
|
||||
if not check_token():
|
||||
return redirect('/admin')
|
||||
|
||||
form = AboutForm()
|
||||
if form.validate_on_submit():
|
||||
|
||||
upload(form, 'about', 'about', False)
|
||||
|
||||
db.session.query(About).\
|
||||
filter(About.id == 0).\
|
||||
update({'text': form.text.data})
|
||||
db.session.commit()
|
||||
|
||||
return redirect('/admin/panel#about')
|
||||
|
||||
@app.route('/admin/pswd', methods=['POST'])
|
||||
def change_pswd():
|
||||
|
||||
if not check_token():
|
||||
return redirect('/admin')
|
||||
|
||||
form = PasswordForm()
|
||||
if form.validate_on_submit():
|
||||
|
||||
if form.newpswd.data != form.confirm.data:
|
||||
flash('E:Пароли не совпадают')
|
||||
return redirect('/admin/panel#account')
|
||||
|
||||
adminpswd = Config.query.filter(Config.id == 0).first().adminpswd
|
||||
|
||||
if bcrypt.check_password_hash(adminpswd, form.oldpswd.data):
|
||||
|
||||
adminpswd = bcrypt.generate_password_hash(form.newpswd.data)
|
||||
access[0] = ''
|
||||
db.session.\
|
||||
query(Config).filter(Config.id == 0).\
|
||||
update({'adminpswd': adminpswd})
|
||||
db.session.commit()
|
||||
|
||||
resp = make_response(redirect('/admin'))
|
||||
resp.delete_cookie('lesa_admin')
|
||||
return resp
|
||||
|
||||
flash('E:Неверный пароль')
|
||||
return redirect('/admin/panel#account')
|
||||
|
||||
flash('F:' + show_errors(form))
|
||||
return redirect('/admin/panel#account')
|
||||
|
||||
def upload(
|
||||
form:FlaskForm,
|
||||
post:Union[int,str],
|
||||
target:str='posts',
|
||||
remove:bool=True) -> None:
|
||||
|
||||
pics = request.files.getlist(form.photos.name)
|
||||
if (not pics) or (not pics[0]):
|
||||
check_dirs(post)
|
||||
return redirect('/admin/panel#' + target)
|
||||
|
||||
save_imgs(pics, post, remove)
|
||||
|
||||
count = len(pics)
|
||||
ending = \
|
||||
'я' if count == 1 else \
|
||||
'и' if count < 5 else 'й'
|
||||
flash(f'I:{count} фотографи{ending} загружено')
|
56
lesa/admin/regdb.py
Normal file
56
lesa/admin/regdb.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
from flask import redirect
|
||||
from flask import request
|
||||
from typing import Any
|
||||
|
||||
from ..app import app, db
|
||||
from ..models import Register, Config
|
||||
|
||||
from . import check_token
|
||||
|
||||
@app.route('/admin/payment', methods=['POST'])
|
||||
def payment_done():
|
||||
|
||||
if not check_token(xhr=True):
|
||||
return redirect('/admin')
|
||||
|
||||
data = request.get_json(force=True)
|
||||
mid = data.get('mid')
|
||||
state = bool(data.get('state', True))
|
||||
|
||||
db.session.\
|
||||
query(Register).filter(Register.mid == mid).\
|
||||
update({'payment': state})
|
||||
db.session.commit()
|
||||
|
||||
return ''
|
||||
|
||||
def change_regallow(state:bool) -> Any:
|
||||
|
||||
if not check_token(xhr=True):
|
||||
return redirect('/admin')
|
||||
|
||||
db.session\
|
||||
.query(Config).filter(Config.id == 0)\
|
||||
.update({'reg_allow': state})
|
||||
db.session.commit()
|
||||
|
||||
return ''
|
||||
|
||||
@app.route('/admin/closereg', methods=['POST'])
|
||||
def close_reg():
|
||||
return change_regallow(False)
|
||||
|
||||
@app.route('/admin/openreg', methods=['POST'])
|
||||
def open_reg():
|
||||
return change_regallow(True)
|
||||
|
||||
@app.route('/admin/clear', methods=['POST'])
|
||||
def clear_reg():
|
||||
|
||||
if not check_token(xhr=True):
|
||||
return redirect('/admin')
|
||||
|
||||
db.session.query(Register).delete()
|
||||
db.session.commit()
|
||||
|
||||
return ''
|
118
lesa/admin/season.py
Normal file
118
lesa/admin/season.py
Normal file
|
@ -0,0 +1,118 @@
|
|||
from flask import redirect
|
||||
from flask import flash
|
||||
from flask import request
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import MultipleFileField
|
||||
|
||||
from ..app import app, db
|
||||
from ..reg import show_errors
|
||||
from ..upload import save_imgs
|
||||
from ..models import Config, Shift, House
|
||||
|
||||
from . import check_token
|
||||
from .adminforms import SeasonForm
|
||||
|
||||
mapurl = '/map-widget/v1/-/'
|
||||
|
||||
@app.route('/admin/season', methods=['POST'])
|
||||
def new_season():
|
||||
|
||||
if not check_token():
|
||||
return redirect('/admin')
|
||||
|
||||
form = SeasonForm()
|
||||
if form.validate_on_submit():
|
||||
|
||||
config = db.session.\
|
||||
query(Config).filter(Config.id == 0)
|
||||
|
||||
config.update({
|
||||
'year': form.year.data,
|
||||
'meal': form.meal.data,
|
||||
'child': form.child.data,
|
||||
'parent': form.parent.data,
|
||||
'name': str(form.name.data)
|
||||
})
|
||||
flash('I:Цены установлены')
|
||||
|
||||
shifts(form)
|
||||
houses(form)
|
||||
|
||||
processimg(form.photos)
|
||||
changemap(form.embed.data, config)
|
||||
|
||||
db.session.commit()
|
||||
flash('I:Готово')
|
||||
return redirect('/admin/panel#season')
|
||||
|
||||
flash('F:' + show_errors(form))
|
||||
return redirect('/admin/panel#season')
|
||||
|
||||
def shifts(form:FlaskForm):
|
||||
Shift.query.delete()
|
||||
for i, sh in enumerate(form.shifts.data,1):
|
||||
# окончание для числительного номера смены:
|
||||
# 1-ая, 2-ая, 10-ая, 100500-ая, но
|
||||
# 3-я, 23-я, 33-я, 43-я
|
||||
ending = 'ая'
|
||||
if i % 10 == 3 and i != 13:
|
||||
ending = 'я'
|
||||
# добавляем
|
||||
db.session.add(Shift(
|
||||
begin=sh['begin'],
|
||||
end=sh['end'],
|
||||
# Пример к коду ниже:
|
||||
# 1-ая: 01.07 - 11.07
|
||||
title=\
|
||||
f'{i}-{ending}: '
|
||||
f'{sh["begin"]:%d.%m}-'
|
||||
f'{sh["end"]:%d.%m}'
|
||||
))
|
||||
flash('I:Смены обновлены')
|
||||
|
||||
def houses(form:FlaskForm):
|
||||
House.query.delete()
|
||||
for h in form.houses.data:
|
||||
db.session.add(House(
|
||||
price=h['price'],
|
||||
title=\
|
||||
f'{h["name"]}: '
|
||||
f'{h["price"]} руб./чел.'
|
||||
))
|
||||
flash('I:Домики обновлены')
|
||||
|
||||
def processimg(photos:MultipleFileField) -> None:
|
||||
|
||||
pics = request.files.getlist(photos.name)
|
||||
if (not pics) or (not pics[0]):
|
||||
flash('W:Не загружены фотографии')
|
||||
return
|
||||
|
||||
save_imgs(pics)
|
||||
|
||||
count = len(pics)
|
||||
ending = \
|
||||
'я' if count == 1 else \
|
||||
'и' if count < 5 else 'й'
|
||||
flash(f'I:{count} фотографи{ending} загружено')
|
||||
|
||||
def changemap(embed, config) -> None:
|
||||
|
||||
# map id is after the prefix
|
||||
# (/map-widget/v1/-/)
|
||||
# and before a closing double quote
|
||||
prefix = embed.find(mapurl)
|
||||
closing = embed.find('"', prefix + 1)
|
||||
|
||||
if prefix < 0 or closing < 0:
|
||||
flash('E:Не удалось найти ссылку на Яндекс.Карты')
|
||||
return
|
||||
|
||||
# skip /map-widget/v1/-/
|
||||
prefix += len(mapurl)
|
||||
# extract id
|
||||
mapid = embed[prefix:closing]
|
||||
# update in db
|
||||
config.update({'mapid':mapid})
|
||||
# show notification
|
||||
flash('I:Карта обновлена')
|
29
lesa/app.py
Normal file
29
lesa/app.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
import secrets
|
||||
from flask import Flask
|
||||
from flask_bcrypt import Bcrypt
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
|
||||
from .upload import check_dirs, basedir
|
||||
|
||||
sql = 'sqlite:///' + basedir + '/sql/database.db'
|
||||
app = Flask(__name__)
|
||||
app.config.update({
|
||||
'SECRET_KEY': secrets.token_hex(),
|
||||
'SQLALCHEMY_DATABASE_URI': sql,
|
||||
'SQLALCHEMY_TRACK_MODIFICATIONS': False,
|
||||
'MAX_CONTENT_LENGTH': 16 * 1024 * 1024 # 16 MiB
|
||||
})
|
||||
|
||||
bcrypt = Bcrypt(app)
|
||||
db = SQLAlchemy(app)
|
||||
migrate = Migrate(app, db)
|
||||
|
||||
check_dirs()
|
||||
|
||||
from . import models
|
||||
from . import pages
|
||||
from . import reg
|
||||
from . import admin
|
||||
|
||||
models.init_db()
|
3
lesa/consts.py
Normal file
3
lesa/consts.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
FONT = 'https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap'
|
||||
DEF_PSWD = '$2b$12$QxRORiIKv7Qm3M68BQ04yeTlGRUNlSMNsczQZmjO4aruwB3vnjwpu'
|
||||
XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
131
lesa/forms.py
Normal file
131
lesa/forms.py
Normal file
|
@ -0,0 +1,131 @@
|
|||
import phonenumbers
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField
|
||||
from wtforms import IntegerField, RadioField
|
||||
from wtforms import DateField, TelField
|
||||
from wtforms import FieldList, FormField
|
||||
from wtforms import HiddenField
|
||||
from wtforms import validators
|
||||
|
||||
from .models import Shift, House
|
||||
|
||||
# https://regexr.com/6n9s6
|
||||
tel_regex = r'^\+?(\d+?)?([ \-()]*\d+){4}'
|
||||
|
||||
def phone_number(value:str) -> str:
|
||||
|
||||
'''Приведение телефонного номера к одному формату'''
|
||||
|
||||
# если указан украинский номер,
|
||||
# но без плюса в начале
|
||||
if value.startswith('380'):
|
||||
value = '+' + value
|
||||
|
||||
# парсинг
|
||||
# (работает даже без кода страны!)
|
||||
result = phonenumbers.parse(value, 'RU')
|
||||
|
||||
# пример: +79123456789
|
||||
return f'+{result.country_code}{result.national_number}'
|
||||
|
||||
class ParentForm(FlaskForm):
|
||||
surname = StringField(label='Фамилия')
|
||||
firstname = StringField(label='Имя')
|
||||
midname = StringField(label='Отчество')
|
||||
phone = TelField(
|
||||
label='Номер телефона',
|
||||
validators=[validators.Regexp(tel_regex)]
|
||||
)
|
||||
email = StringField(
|
||||
label='E-mail',
|
||||
validators=[validators.Email()]
|
||||
)
|
||||
social = StringField(
|
||||
label='VK/FB/Instagram',
|
||||
validators=[
|
||||
validators.URL(),
|
||||
validators.Optional()
|
||||
]
|
||||
)
|
||||
|
||||
class ChildForm(FlaskForm):
|
||||
surname = StringField(label='Фамилия')
|
||||
firstname = StringField(label='Имя')
|
||||
gender = RadioField(label='Пол ребёнка', choices=[(0,'М'),(1,'Ж')])
|
||||
birthday = DateField(label='Дата рождения')
|
||||
|
||||
class RegisterForm(FlaskForm):
|
||||
family = StringField(
|
||||
label='Фамилия семьи',
|
||||
validators=[validators.DataRequired()]
|
||||
)
|
||||
dates = RadioField(
|
||||
label='Смена',
|
||||
validators=[validators.DataRequired()]
|
||||
)
|
||||
count = IntegerField(
|
||||
'Кол-во человек',
|
||||
validators=[
|
||||
validators.DataRequired(),
|
||||
validators.NumberRange(min=1,max=20)
|
||||
]
|
||||
)
|
||||
children = IntegerField(
|
||||
'Кол-во детей',
|
||||
validators=[
|
||||
validators.DataRequired(),
|
||||
validators.NumberRange(min=1,max=10)
|
||||
]
|
||||
)
|
||||
meal_count = IntegerField(
|
||||
'Питание',
|
||||
validators=[
|
||||
validators.DataRequired(),
|
||||
validators.NumberRange(min=0,max=20)
|
||||
]
|
||||
)
|
||||
house = RadioField(
|
||||
label='Проживание',
|
||||
validators=[validators.DataRequired()]
|
||||
)
|
||||
# Из Google Формы:
|
||||
# С кем из друзей Вы хотите жить в одном домике?
|
||||
# Организаторы постараются учесть пожелания, но не гарантируют их исполнение.
|
||||
friends = StringField(
|
||||
label=
|
||||
'Фамилия друзей, с которыми '
|
||||
'Вы хотите жить в одном домике',
|
||||
)
|
||||
|
||||
parentslst = FieldList(
|
||||
FormField(ParentForm),
|
||||
label='Данные родителей',
|
||||
min_entries=1, max_entries=2
|
||||
)
|
||||
childrenlst = FieldList(
|
||||
FormField(ChildForm),
|
||||
label='Данные детей',
|
||||
min_entries=1, max_entries=10
|
||||
)
|
||||
|
||||
main_email = RadioField(
|
||||
label='Основная почта',
|
||||
validators=[validators.DataRequired()],
|
||||
choices=['', '']
|
||||
)
|
||||
main_phone = RadioField(
|
||||
label='Основной номер телефона',
|
||||
validators=[validators.DataRequired()],
|
||||
choices=['', '']
|
||||
)
|
||||
|
||||
code = HiddenField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
shifts = Shift.query.all()
|
||||
houses = House.query.all()
|
||||
self.dates.choices = [(s.id, s.title) for s in shifts]
|
||||
self.house.choices = [(h.price, h.title) for h in houses]
|
123
lesa/models.py
Normal file
123
lesa/models.py
Normal file
|
@ -0,0 +1,123 @@
|
|||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
|
||||
from .app import db
|
||||
from .consts import DEF_PSWD
|
||||
|
||||
def get_utc():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
class Post(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(64))
|
||||
dt = db.Column(db.TIMESTAMP, default=get_utc)
|
||||
body = db.Column(db.Text)
|
||||
|
||||
class About(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
text = db.Column(db.Text)
|
||||
|
||||
class Shift(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(64))
|
||||
begin = db.Column(db.Date)
|
||||
end = db.Column(db.Date)
|
||||
|
||||
class House(db.Model):
|
||||
price = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(64))
|
||||
|
||||
class Config(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
year = db.Column(db.Integer)
|
||||
meal = db.Column(db.Integer)
|
||||
child = db.Column(db.Integer)
|
||||
parent = db.Column(db.Integer)
|
||||
name = db.Column(db.String(64))
|
||||
mapid = db.Column(db.String(32))
|
||||
reg_allow = db.Column(db.Boolean)
|
||||
adminpswd = db.Column(db.String(60))
|
||||
actions = db.Column(db.String(8))
|
||||
|
||||
class AdminColumn(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(16))
|
||||
ctype = db.Column(db.String(4))
|
||||
title = db.Column(db.String(32))
|
||||
|
||||
class Register(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
family = db.Column(db.String(32))
|
||||
dates = db.Column(db.Integer)
|
||||
count = db.Column(db.Integer, default=2)
|
||||
children = db.Column(db.Integer, default=1)
|
||||
meal = db.Column(db.Integer, default=0)
|
||||
house = db.Column(db.Integer)
|
||||
friends = db.Column(db.String(32))
|
||||
email = db.Column(db.String(64))
|
||||
phone = db.Column(db.String(32))
|
||||
payment = db.Column(db.Boolean)
|
||||
code = db.Column(db.String(8))
|
||||
mid = db.Column(db.String(32))
|
||||
|
||||
class Parent(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
mid = db.Column(db.String(32))
|
||||
surname = db.Column(db.String(32))
|
||||
firstname = db.Column(db.String(32))
|
||||
midname = db.Column(db.String(32))
|
||||
phone = db.Column(db.String(32))
|
||||
email = db.Column(db.String(64))
|
||||
social = db.Column(db.String(64))
|
||||
|
||||
class Child(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
mid = db.Column(db.String(32))
|
||||
surname = db.Column(db.String(32))
|
||||
firstname = db.Column(db.String(32))
|
||||
gender = db.Column(db.Integer, default=False)
|
||||
birthday = db.Column(db.Date)
|
||||
|
||||
class BeenLastYear(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(8))
|
||||
|
||||
def init_db():
|
||||
|
||||
db.create_all()
|
||||
if Config.query.count() == 0:
|
||||
c = Config(
|
||||
id=0, year=2022,
|
||||
meal=0, child=0, parent=0,
|
||||
name='Не указано', mapid='0',
|
||||
reg_allow=True,
|
||||
adminpswd=DEF_PSWD,
|
||||
actions='mpvc'
|
||||
)
|
||||
db.session.add(c)
|
||||
|
||||
if About.query.count() == 0:
|
||||
a = About(
|
||||
id=0, text='Пусто'
|
||||
)
|
||||
db.session.add(a)
|
||||
|
||||
if AdminColumn.query.count() == 0:
|
||||
for k, v in {
|
||||
'id': ('№', 'num'),
|
||||
'actions': ('Действия', ''),
|
||||
'family': ('Семья', 'str'),
|
||||
'dates': ('Смена', 'num'),
|
||||
'count': ('Кол-во', 'num'),
|
||||
'adults': ('Взрослые', 'num'),
|
||||
'children': ('Дети', 'num'),
|
||||
'meal': ('Питание', 'num'),
|
||||
'house': ('Дом', 'num'),
|
||||
'friends': ('Друзья', 'str'),
|
||||
'cost': ('Стоимость', 'num')
|
||||
}.items():
|
||||
db.session.add(AdminColumn(
|
||||
key=k, title=v[0], ctype=v[1]
|
||||
))
|
||||
|
||||
db.session.commit()
|
95
lesa/pages.py
Normal file
95
lesa/pages.py
Normal file
|
@ -0,0 +1,95 @@
|
|||
from datetime import datetime
|
||||
from flask import render_template
|
||||
|
||||
from .app import app
|
||||
from .forms import RegisterForm
|
||||
from .upload import post_dirs
|
||||
from .upload import html_dirs
|
||||
from .upload import listdir
|
||||
from .consts import FONT
|
||||
from .models import Config
|
||||
from .models import Post, About
|
||||
from .models import Shift, House
|
||||
|
||||
photos0_html, thumbs0_html = html_dirs(0)
|
||||
photos0_dir = post_dirs(0)[0]
|
||||
|
||||
@app.route('/')
|
||||
@app.route('/index')
|
||||
def index():
|
||||
return render_template('index.html', font=FONT)
|
||||
|
||||
@app.route('/page/main')
|
||||
def mainpage():
|
||||
|
||||
config = Config.query.filter(Config.id == 0).first()
|
||||
posts = Post.query.order_by(Post.id.desc()).all()
|
||||
|
||||
return render_template(
|
||||
'main.html',
|
||||
news=posts,
|
||||
year=config.year,
|
||||
shiftdb=Shift.query.all(),
|
||||
housedb=House.query.all(),
|
||||
place={
|
||||
'name': config.name,
|
||||
'mapid': config.mapid
|
||||
},
|
||||
listdir=listdir,
|
||||
post_dirs=post_dirs,
|
||||
html_dirs=html_dirs
|
||||
)
|
||||
|
||||
@app.route('/page/register')
|
||||
def register():
|
||||
|
||||
config = Config.query.filter(Config.id == 0).first()
|
||||
|
||||
if not config.reg_allow:
|
||||
return render_template('closed.html')
|
||||
|
||||
shift = Shift.query.filter(Shift.id == 1).first()
|
||||
if not shift:
|
||||
shift = datetime.now()
|
||||
else:
|
||||
shift = datetime(
|
||||
year=shift.begin.year,
|
||||
month=shift.begin.month,
|
||||
day=shift.begin.day
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'register.html',
|
||||
form=RegisterForm(),
|
||||
shift=shift.timestamp(),
|
||||
price={
|
||||
'meal': config.meal,
|
||||
'child': config.child,
|
||||
'parent': config.parent
|
||||
}
|
||||
)
|
||||
|
||||
@app.route('/page/about')
|
||||
def about():
|
||||
|
||||
photos_html, thumbs_html = html_dirs('about')
|
||||
return render_template(
|
||||
'about.html',
|
||||
about=About.query.\
|
||||
filter(About.id == 0).\
|
||||
first(),
|
||||
photos_dir=photos_html,
|
||||
thumbs_dir=thumbs_html
|
||||
)
|
||||
|
||||
@app.route('/page/photos<int:post>')
|
||||
def photos(post):
|
||||
|
||||
photos_html, thumbs_html = html_dirs(post)
|
||||
photos_dir = post_dirs(post)[0]
|
||||
return render_template(
|
||||
'photos.html',
|
||||
photos=listdir(photos_dir),
|
||||
photos_dir=photos_html,
|
||||
thumbs_dir=thumbs_html
|
||||
)
|
128
lesa/reg.py
Normal file
128
lesa/reg.py
Normal file
|
@ -0,0 +1,128 @@
|
|||
import json
|
||||
import secrets
|
||||
from flask import request
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import FieldList
|
||||
from typing import Dict, List, Union, Optional
|
||||
|
||||
from .app import app, db
|
||||
from .forms import RegisterForm
|
||||
from .forms import phone_number
|
||||
from .models import Register
|
||||
from .models import Parent, Child
|
||||
from .models import BeenLastYear
|
||||
from .models import Config
|
||||
|
||||
def show_errors(
|
||||
form:FlaskForm,
|
||||
errs:Optional[Dict[str,str]]=None,
|
||||
lst:bool=False) -> Union[List[str],str]:
|
||||
|
||||
'''
|
||||
Вывод label-ов некорректно заполненных полей,
|
||||
например, для сообщения об ошибке'''
|
||||
|
||||
errs = errs or form.errors
|
||||
labels = []
|
||||
|
||||
for e in errs:
|
||||
|
||||
field = form[e]
|
||||
lbl = field.label.text
|
||||
|
||||
if isinstance(field, FieldList):
|
||||
inner = []
|
||||
lbl += ' ('
|
||||
for f in field:
|
||||
inner.append(show_errors(f, f.errors))
|
||||
lbl += '; '.join(inner)
|
||||
lbl += ')'
|
||||
|
||||
labels.append(lbl)
|
||||
|
||||
if lst: return labels
|
||||
return ', '.join(labels).lower()
|
||||
|
||||
@app.route('/form/code', methods=['POST'])
|
||||
def check_code():
|
||||
|
||||
data = request.get_json(force=True)
|
||||
codes = BeenLastYear.query.filter(
|
||||
BeenLastYear.code == data['code']
|
||||
).all()
|
||||
|
||||
if len(codes) > 0:
|
||||
return json.dumps({'data':True})
|
||||
|
||||
return json.dumps({'data':False})
|
||||
|
||||
@app.route('/form/register', methods=['POST'])
|
||||
def regform():
|
||||
|
||||
config = Config.query.filter(Config.id == 0).first()
|
||||
|
||||
if not config.reg_allow:
|
||||
return json.dumps({
|
||||
'ok': False,
|
||||
'data': 'Регистрация пока закрыта'
|
||||
})
|
||||
|
||||
form = RegisterForm()
|
||||
form.main_email.choices = [form.main_email.data]
|
||||
form.main_phone.choices = [form.main_phone.data]
|
||||
if form.validate_on_submit():
|
||||
|
||||
data = form.data
|
||||
parents = data['parentslst']
|
||||
children = data['childrenlst']
|
||||
|
||||
mid = secrets.token_urlsafe(16)
|
||||
email = data['main_email']
|
||||
phone = data['main_phone']
|
||||
|
||||
db.session.add(Register(
|
||||
family=data['family'],
|
||||
dates=data['dates'],
|
||||
count=data['count'],
|
||||
children=data['children'],
|
||||
meal=data['meal_count'],
|
||||
house=data['house'],
|
||||
friends=data['friends'],
|
||||
email=email,
|
||||
phone=phone,
|
||||
code=data['code'],
|
||||
mid=mid
|
||||
))
|
||||
|
||||
for p in parents:
|
||||
phone = phone_number(p['phone'])
|
||||
db.session.add(Parent(
|
||||
mid=mid,
|
||||
surname=p['surname'],
|
||||
firstname=p['firstname'],
|
||||
midname=p['midname'],
|
||||
phone=phone,
|
||||
email=p['email'],
|
||||
social=p['social']
|
||||
))
|
||||
|
||||
for c in children:
|
||||
db.session.add(Child(
|
||||
mid=mid,
|
||||
surname=c['surname'],
|
||||
firstname=c['firstname'],
|
||||
gender=c['gender'],
|
||||
birthday=c['birthday']
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
return json.dumps({'ok':True,'data':mid})
|
||||
|
||||
return json.dumps({
|
||||
'ok': False,
|
||||
'data': show_errors(
|
||||
form=form,
|
||||
errs=None,
|
||||
lst=True
|
||||
)
|
||||
})
|
156
lesa/reg.py.old
Normal file
156
lesa/reg.py.old
Normal file
|
@ -0,0 +1,156 @@
|
|||
import json
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from flask import request
|
||||
from flask_wtf import FlaskForm
|
||||
|
||||
from app import app, db
|
||||
from forms import RegisterForm
|
||||
from forms import phone_number
|
||||
|
||||
regdb = {}
|
||||
|
||||
def show_errors(form:FlaskForm) -> list:
|
||||
|
||||
'''
|
||||
Вывод label для некорректно заполненных полей,
|
||||
например, для сообщения об ошибке'''
|
||||
|
||||
# лейблы для полей
|
||||
# с некорректными данными
|
||||
err_labels = []
|
||||
|
||||
# для каждого поля в форме
|
||||
for f in form:
|
||||
# если у него есть ошибки
|
||||
name = f.short_name
|
||||
if name in form.errors:
|
||||
# добавляем его лейбл в список
|
||||
err_labels.append(f.label.text)
|
||||
|
||||
return err_labels
|
||||
|
||||
@app.route('/form/register', methods=['POST'])
|
||||
def regform():
|
||||
|
||||
from models import Config
|
||||
config = Config.query.filter(Config.id == 0).first()
|
||||
|
||||
if not config.reg_allow:
|
||||
return json.dumps({'ok':False,'data':'Регистрация пока закрыта'})
|
||||
|
||||
form = RegisterForm()
|
||||
if form.validate_on_submit():
|
||||
|
||||
session = secrets.token_urlsafe(10)
|
||||
# А такое может быть?
|
||||
if session in regdb:
|
||||
return json.dumps({'ok':False,'data':'Совпадающие идентификаторы'})
|
||||
|
||||
fields = form.data
|
||||
del fields['csrf_token']
|
||||
regdb[session] = {
|
||||
'main': fields,
|
||||
'parents': [],
|
||||
'children': [],
|
||||
'created': datetime.now(timezone.utc)
|
||||
}
|
||||
return json.dumps({'ok':True,'data':session})
|
||||
|
||||
return json.dumps({'ok':False,'data':show_errors(form)})
|
||||
|
||||
@app.route('/form/parents', methods=['POST'])
|
||||
def parentform():
|
||||
|
||||
session = request.cookies.get('lesa_session', '0')
|
||||
if not (session in regdb):
|
||||
return json.dumps({'ok':False,'data':'Некорректный ID сессии!'})
|
||||
|
||||
form = ParentForm()
|
||||
if form.validate_on_submit():
|
||||
|
||||
user = regdb[session]
|
||||
if len(user['parents']) >= 2:
|
||||
return json.dumps({'ok':False,'data':'Уже зарегистрировано 2 родителя'})
|
||||
|
||||
fields = form.data
|
||||
del fields['csrf_token']
|
||||
fields['phone'] = phone_number(form.phone.data)
|
||||
user['parents'].append(fields)
|
||||
return json.dumps({'ok':True,'data':session})
|
||||
|
||||
return json.dumps({'ok':False,'data':show_errors(form)})
|
||||
|
||||
@app.route('/form/children', methods=['POST'])
|
||||
def childform():
|
||||
|
||||
session = request.cookies.get('lesa_session', '0')
|
||||
if not (session in regdb):
|
||||
return json.dumps({'ok':False,'data':'Некорректный ID сессии!'})
|
||||
|
||||
form = ChildForm()
|
||||
if form.validate_on_submit():
|
||||
|
||||
user = regdb[session]
|
||||
fields = form.data
|
||||
del fields['csrf_token']
|
||||
user['children'].append(fields)
|
||||
return json.dumps({'ok':True,'data':session})
|
||||
|
||||
return json.dumps({'ok':False,'data':show_errors(form)})
|
||||
|
||||
@app.route('/form/complete', methods=['GET'])
|
||||
def complete():
|
||||
|
||||
session = request.cookies.get('lesa_session', '0')
|
||||
if not (session in regdb):
|
||||
return json.dumps({'ok':False,'data':'Некорректный ID сессии!'})
|
||||
|
||||
from models import Register, Parent, Child
|
||||
|
||||
user = regdb[session]
|
||||
main = user.get('main', {})
|
||||
parents = user.get('parents', [])
|
||||
children = user.get('children', [])
|
||||
mid = secrets.token_urlsafe(16)
|
||||
|
||||
mail = ''
|
||||
if len(parents) > 0:
|
||||
mail = parents[0].get('email')
|
||||
|
||||
db.session.add(Register(
|
||||
family=main.get('family'),
|
||||
dates=main.get('dates'),
|
||||
count=main.get('count'),
|
||||
children=main.get('children'),
|
||||
house=main.get('house'),
|
||||
friends=main.get('friends'),
|
||||
email=mail,
|
||||
mid=mid
|
||||
))
|
||||
|
||||
for p in parents:
|
||||
db.session.add(Parent(
|
||||
mid=mid,
|
||||
surname=p.get('surname'),
|
||||
firstname=p.get('firstname'),
|
||||
midname=p.get('midname'),
|
||||
phone=p.get('phone'),
|
||||
email=p.get('email'),
|
||||
social=p.get('social')
|
||||
))
|
||||
|
||||
for c in children:
|
||||
db.session.add(Child(
|
||||
mid=mid,
|
||||
surname=c.get('surname'),
|
||||
firstname=c.get('firstname'),
|
||||
gender=c.get('gender'),
|
||||
birthday=c.get('birthday')
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
del user
|
||||
del regdb[session]
|
||||
return json.dumps({'ok':True,'data':''})
|
251
lesa/static/css/admin.css
Normal file
251
lesa/static/css/admin.css
Normal file
|
@ -0,0 +1,251 @@
|
|||
:root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: var(--theme-bg);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: none;
|
||||
background-color: var(--theme-bg);
|
||||
padding: 10px;
|
||||
}
|
||||
.tab:target {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 910px) {
|
||||
.tab:target {
|
||||
justify-content: start;
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
justify-content: start !important;
|
||||
}
|
||||
|
||||
.menu-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
ul.tabs-title {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: var(--theme-bg);
|
||||
}
|
||||
ul.tabs-title > li:hover {
|
||||
background: var(--theme-bg);
|
||||
filter: brightness(120%);
|
||||
}
|
||||
ul.tabs-title > li.active {
|
||||
border-bottom: 2px solid var(--leaf-bg);
|
||||
}
|
||||
ul.tabs-title > li > a {
|
||||
display: inline-block;
|
||||
padding: 10px;
|
||||
text-decoration: none;
|
||||
color: var(--theme-fg);
|
||||
}
|
||||
|
||||
ul.family-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
ul.family-list li {
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
padding: 5px;
|
||||
border: 2px solid #bbb;
|
||||
border-radius: 5px;
|
||||
}
|
||||
ul.family-list .name.male i {
|
||||
color: #496fa1;
|
||||
}
|
||||
ul.family-list .name.female i {
|
||||
color: #af5073;
|
||||
}
|
||||
|
||||
ul.family-list a {
|
||||
color: var(--leaf-bg);
|
||||
}
|
||||
ul.family-list a:hover {
|
||||
filter: brightness(70%);
|
||||
}
|
||||
|
||||
.regdb-wrapper {
|
||||
max-height: 89vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
max-height: 89vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.table-wrapper::-webkit-scrollbar {
|
||||
width: 0.7rem;
|
||||
}
|
||||
.table-wrapper::-webkit-scrollbar-track {
|
||||
background: var(--darker-bg);
|
||||
border-radius: 5px;
|
||||
}
|
||||
.table-wrapper::-webkit-scrollbar-thumb {
|
||||
background: var(--light-bg);
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--darker-bg);
|
||||
}
|
||||
.table-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--highlight-bg);
|
||||
}
|
||||
|
||||
.regdb-wrapper .actions {
|
||||
margin-bottom: 5px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
}
|
||||
.regdb-wrapper .actions a {
|
||||
width: 1rem;
|
||||
margin-left: 5px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
.regdb-wrapper .top-actions,
|
||||
.regdb-wrapper .bottom-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
table {
|
||||
border-spacing: 10px;
|
||||
}
|
||||
|
||||
td {
|
||||
text-align: center;
|
||||
}
|
||||
td.done a.link.payment {
|
||||
color: var(--leaf-bg);
|
||||
}
|
||||
|
||||
table a.button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.post a.button {
|
||||
margin: 0;
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.post {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.post::after {
|
||||
content: '';
|
||||
display: block;
|
||||
background-color: var(--theme-bg);
|
||||
filter: brightness(80%);
|
||||
width: 100%;
|
||||
height: 0.15rem;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
a.link {
|
||||
display: inline-block;
|
||||
font-size: 1.1rem;
|
||||
padding: 0 2px;
|
||||
color: var(--theme-fg);
|
||||
background: var(--theme-bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
a.link:hover {
|
||||
filter: brightness(120%);
|
||||
}
|
||||
a.link.small i {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
a.link.medium i {
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
ul.toolbar {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-top: 5px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--theme-fg);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
ul.toolbar > li > a > i.fa-solid {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
ul.toolbar > li > a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
padding: 7px;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
color: var(--theme-fg);
|
||||
background: var(--theme-bg);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
ul.toolbar > li > a:hover {
|
||||
filter: brightness(150%);
|
||||
}
|
||||
|
||||
ul.toolbar s {
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
ul.toolbar s::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
border-top: 1px solid var(--theme-fg);
|
||||
border-bottom: 1px solid var(--theme-bg);
|
||||
margin-top: 1px;
|
||||
}
|
96
lesa/static/css/adminform.css
Normal file
96
lesa/static/css/adminform.css
Normal file
|
@ -0,0 +1,96 @@
|
|||
body {
|
||||
--font: 'Nunito', sans-serif;
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
max-height: 89vh;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 15px #000;
|
||||
}
|
||||
|
||||
.separated > form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.separated > form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.form-part {
|
||||
margin: 10px;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 8px var(--leaf-bg);
|
||||
}
|
||||
.form-part .fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-top: 5px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
label:nth-of-type(1) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.fields-title {
|
||||
color: var(--leaf-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
form input,
|
||||
form textarea {
|
||||
margin-top: 3px;
|
||||
font-size: 1.05rem;
|
||||
background: var(--theme-bg);
|
||||
color: var(--theme-fg);
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
form input:not([type=submit]):not([type=button]),
|
||||
form textarea {
|
||||
border-bottom: 1px solid var(--leaf-bg);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
form input::-webkit-outer-spin-button,
|
||||
form input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
form input[type=submit],
|
||||
form input[type=button],
|
||||
form .button {
|
||||
display: inline-block;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
background: var(--leaf-bg);
|
||||
color: #ddd;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: filter 0.2s ease-out 0s;
|
||||
font-family: var(--font);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
form input[type=submit]:hover,
|
||||
form input[type=button]:hover,
|
||||
form .button:hover {
|
||||
filter: brightness(80%);
|
||||
}
|
27
lesa/static/css/colors.css
Normal file
27
lesa/static/css/colors.css
Normal file
|
@ -0,0 +1,27 @@
|
|||
:root {
|
||||
--leaf-fg: #9acd32;
|
||||
--leaf-bg: #7ba428;
|
||||
--wood-bg: #5b3a29;
|
||||
}
|
||||
|
||||
body {
|
||||
--theme-fg: #000;
|
||||
--theme-bg: #dfdfdf;
|
||||
--light-bg: #dfdfdf;
|
||||
--darker-bg: #bfbfbf;
|
||||
--highlight-bg: #888;
|
||||
color: var(--theme-fg);
|
||||
}
|
||||
body.dark {
|
||||
--theme-fg: #ddd;
|
||||
--theme-bg: #2b2a33;
|
||||
--light-bg: #474554;
|
||||
--darker-bg: #222027;
|
||||
--highlight-bg: #76738c;
|
||||
}
|
||||
|
||||
::selection {
|
||||
color: var(--theme-bg);
|
||||
background: var(--leaf-bg);
|
||||
text-shadow: 0 0 5px var(--theme-fg);
|
||||
}
|
61
lesa/static/css/context.css
Normal file
61
lesa/static/css/context.css
Normal file
|
@ -0,0 +1,61 @@
|
|||
ul.context-menu {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
ul.context-menu.open {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 10rem;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
list-style: none;
|
||||
background-color: var(--theme-bg);
|
||||
border: 1px solid var(--theme-fg);
|
||||
border-radius: 5px;
|
||||
z-index: 999;
|
||||
animation: menu-fadein 0.2s ease 0s 1 normal forwards;
|
||||
}
|
||||
|
||||
ul.context-menu a {
|
||||
display: block;
|
||||
padding: 2px 5px;
|
||||
text-decoration: none;
|
||||
color: var(--theme-fg);
|
||||
background: var(--theme-bg);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
ul.context-menu i {
|
||||
width: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
ul.context-menu li.centered {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
ul.context-menu li.offset {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
ul.context-menu input,
|
||||
ul.context-menu select {
|
||||
display: block;
|
||||
width: 9.2rem;
|
||||
height: 1.3rem;
|
||||
}
|
||||
|
||||
ul.context-menu select {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
@keyframes menu-fadein {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
93
lesa/static/css/forms.css
Normal file
93
lesa/static/css/forms.css
Normal file
|
@ -0,0 +1,93 @@
|
|||
body {
|
||||
--pbwidth: 0%;
|
||||
}
|
||||
|
||||
.forms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.cost {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.cost > .number {
|
||||
color: var(--theme-fg);
|
||||
}
|
||||
.cost > .number.loading {
|
||||
filter: brightness(70%);
|
||||
}
|
||||
|
||||
.forms-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
color: inherit;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.input-field > input {
|
||||
border: none;
|
||||
outline: none;
|
||||
border-bottom: 1px solid var(--leaf-bg);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
width: 20rem;
|
||||
max-width: 100%;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.input-field > input::-webkit-inner-spin-button,
|
||||
.input-field > input::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.input-field > select {
|
||||
border: none;
|
||||
outline: none;
|
||||
width: 20rem;
|
||||
max-width: 100%;
|
||||
font-size: 1.05rem;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.reg-progress {
|
||||
position: relative;
|
||||
height: 0.3rem;
|
||||
border-radius: 1rem;
|
||||
background-color: var(--bg-color);
|
||||
margin-bottom: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.reg-progress::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 0.3rem;
|
||||
border-radius: 1rem;
|
||||
background-color: var(--leaf-bg);
|
||||
width: var(--pbwidth);
|
||||
transition: width 0.3s ease-out 0s;
|
||||
}
|
24
lesa/static/css/login.css
Normal file
24
lesa/static/css/login.css
Normal file
|
@ -0,0 +1,24 @@
|
|||
:root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--theme-bg);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
37
lesa/static/css/menu.css
Normal file
37
lesa/static/css/menu.css
Normal file
|
@ -0,0 +1,37 @@
|
|||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.top-menu {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.top-menu > li {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.top-menu > li > a {
|
||||
display: block;
|
||||
font-size: 1.2rem;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
transition: color 0.25s ease 0s;
|
||||
}
|
||||
|
||||
.top-menu i {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.top-menu > li > a:hover {
|
||||
color: var(--leaf-fg);
|
||||
}
|
54
lesa/static/css/photos.css
Normal file
54
lesa/static/css/photos.css
Normal file
|
@ -0,0 +1,54 @@
|
|||
.photos {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.photo {
|
||||
margin-right: 5px;
|
||||
position: relative;
|
||||
}
|
||||
.photo img {
|
||||
height: 4rem;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
.photo a {
|
||||
display: block;
|
||||
}
|
||||
.photo a:hover {
|
||||
filter: brightness(120%);
|
||||
}
|
||||
|
||||
.photos.large {
|
||||
justify-content: center;
|
||||
}
|
||||
.photos.large img {
|
||||
height: 6rem;
|
||||
}
|
||||
.photos.large .more {
|
||||
height: 6rem;
|
||||
}
|
||||
|
||||
.photo.last a {
|
||||
display: block;
|
||||
color: var(--theme-fg);
|
||||
text-decoration: none;
|
||||
}
|
||||
.more {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 999;
|
||||
}
|
||||
.more-bg {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--theme-bg);
|
||||
opacity: 0.7;
|
||||
z-index: 998;
|
||||
}
|
52
lesa/static/css/posts.css
Normal file
52
lesa/static/css/posts.css
Normal file
|
@ -0,0 +1,52 @@
|
|||
.post-wrapper {
|
||||
padding: 10px 0;
|
||||
border-bottom: 2px solid var(--leaf-bg);
|
||||
}
|
||||
|
||||
.post-wrapper:nth-child(1) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.post-main h1,
|
||||
.post-main h2,
|
||||
.post-main h3,
|
||||
.post-main h4,
|
||||
.post-main h5,
|
||||
.post-main h6,
|
||||
.post-main ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.season h4 {
|
||||
display: inline-block;
|
||||
color: var(--leaf-bg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.season ul {
|
||||
list-style: none;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.post-main ul {
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.post-main a:not(.button) {
|
||||
text-decoration: underline;
|
||||
color: var(--leaf-bg);
|
||||
}
|
||||
|
||||
.post-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.post-row > .cell {
|
||||
margin-right: 10px;
|
||||
}
|
39
lesa/static/css/radio.css
Normal file
39
lesa/static/css/radio.css
Normal file
|
@ -0,0 +1,39 @@
|
|||
.input-field > ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
input[type=radio],
|
||||
input[type=checkbox] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type=radio] ~ label,
|
||||
input[type=checkbox] ~ label {
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input[type=radio] ~ label::before,
|
||||
input[type=checkbox] ~ label::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
margin-top: -0.09rem;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
input[type=radio]:checked ~ label::before,
|
||||
input[type=checkbox]:checked ~ label::before {
|
||||
background: var(--leaf-bg);
|
||||
background: -moz-radial-gradient(circle, var(--leaf-bg) 30%, #fff 40%, #fff 100%);
|
||||
background: -webkit-radial-gradient(circle, var(--leaf-bg) 30%, #fff 40%, #fff 100%);
|
||||
background: radial-gradient(circle, var(--leaf-bg) 30%, #fff 40%, #fff 100%);
|
||||
}
|
108
lesa/static/css/style.css
Normal file
108
lesa/static/css/style.css
Normal file
|
@ -0,0 +1,108 @@
|
|||
:root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: hidden;
|
||||
background-color: var(--wood-bg);
|
||||
background-image: url("../img/paper-2.png");
|
||||
font-family: 'Nunito', sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transition:
|
||||
color 0.4s ease 0s,
|
||||
background-color 0.4s ease 0s;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
overflow-y: auto;
|
||||
background-color: var(--theme-bg);
|
||||
border-top-right-radius: 10px;
|
||||
border-top-left-radius: 10px;
|
||||
padding: 10px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 45rem;
|
||||
}
|
||||
|
||||
@media(min-width: 100rem) {
|
||||
.main-content {
|
||||
max-width: 60rem;
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
background: url(/static/img/lesa.png);
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.closed > i {
|
||||
font-size: 2.5rem;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--leaf-bg);
|
||||
}
|
||||
|
||||
.map {
|
||||
min-height: 35vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
.hidden-title > .button {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: var(--leaf-bg);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
padding: 5px 10px;
|
||||
margin: 5px;
|
||||
border-radius: 0.4rem;
|
||||
transition:
|
||||
transform 0.3s ease-out 0s,
|
||||
filter 0.3s ease-out 0s,
|
||||
background-color 0.3s ease-out 0s;
|
||||
}
|
||||
|
||||
.button.inactive {
|
||||
background-color: #777;
|
||||
}
|
||||
|
||||
.button:not(.inactive):hover {
|
||||
transform: translateY(-0.4rem);
|
||||
filter: brightness(90%);
|
||||
}
|
||||
|
||||
.button:not(.inactive):active {
|
||||
transform: translateY(-0.1rem) !important;
|
||||
}
|
BIN
lesa/static/img/lesa.png
Normal file
BIN
lesa/static/img/lesa.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 141 KiB |
BIN
lesa/static/img/lesa.xcf
Normal file
BIN
lesa/static/img/lesa.xcf
Normal file
Binary file not shown.
BIN
lesa/static/img/paper-2.png
Normal file
BIN
lesa/static/img/paper-2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 43 KiB |
334
lesa/static/js/admin.js
Normal file
334
lesa/static/js/admin.js
Normal file
|
@ -0,0 +1,334 @@
|
|||
addEventListener('load', checkTarget);
|
||||
addEventListener('hashchange', checkTarget);
|
||||
|
||||
addEventListener('load', beenLastYearList);
|
||||
|
||||
const session_regex = /lesa_admin=([\w\-]+)(?:;|$)/;
|
||||
|
||||
var titles = document.querySelectorAll('.tabs-title>li:not(.toggle)');
|
||||
titles.forEach(el => {
|
||||
el.addEventListener('click', activated);
|
||||
});
|
||||
|
||||
function checkTarget() {
|
||||
addr = window.location.hash.replace(/^#/, '');
|
||||
addr = '#' + (addr != '' ? addr : 'posts');
|
||||
window.location.hash = addr;
|
||||
let tab = document.querySelector('.tabs-title>li>a[href="' + addr + '"]');
|
||||
tab.click();
|
||||
}
|
||||
|
||||
function activated(ev) {
|
||||
titles.forEach((el) => {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
ev.currentTarget.classList.add('active');
|
||||
if (!(ev.currentTarget instanceof HTMLAnchorElement)) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function beenLastYearList() {
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/admin/been_last_year');
|
||||
xhr.onloadend = () => {
|
||||
if (xhr.status == 200 &&
|
||||
xhr.response &&
|
||||
xhr.response.constructor == String)
|
||||
{
|
||||
been_last_year =
|
||||
xhr.response
|
||||
.trim()
|
||||
.replace('\r\n', '\n')
|
||||
.split('\n');
|
||||
}
|
||||
};
|
||||
xhr.send(JSON.stringify({
|
||||
token: sessionCookie()
|
||||
}));
|
||||
}
|
||||
|
||||
function photoContextMenu(ev) {
|
||||
if (!(ev instanceof MouseEvent)) return;
|
||||
hideContextMenu();
|
||||
let photo = ev.currentTarget.parentElement;
|
||||
let menu = photo.querySelector('ul.context-menu');
|
||||
menu.classList.add('open');
|
||||
menu.style.top = ev.offsetY + 5 + 'px';
|
||||
menu.style.left = ev.offsetX + 5 + 'px';
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
let menus = document.querySelectorAll('ul.context-menu.open');
|
||||
for (let m of menus) {
|
||||
m.classList.remove('open');
|
||||
}
|
||||
}
|
||||
|
||||
function insertPhoto(id) {
|
||||
edit.insert('about-text', ['',`[кар]${id}[/кар]`]);
|
||||
}
|
||||
|
||||
function deletePhoto(id) {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Удалить фото?',
|
||||
showConfirmButton: true,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Да',
|
||||
cancelButtonText: 'Нет'
|
||||
}).then(result => {
|
||||
if (result.isConfirmed) {
|
||||
adminRequest('rmphoto', id, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removePost(id) {
|
||||
id = Number(id);
|
||||
if (!id) return;
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Удалить пост?',
|
||||
showConfirmButton: true,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Да',
|
||||
cancelButtonText: 'Нет'
|
||||
}).then(result => {
|
||||
if (result.isConfirmed) {
|
||||
adminRequest('rmpost', id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeReg() {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Закрытие регистрации',
|
||||
text:
|
||||
'Когда начинается смена, желательно ' +
|
||||
'закрыть доступ к регистрации, ' +
|
||||
'чтобы никто не мог добавить записи в таблицу. ' +
|
||||
'Открыть регистрацию на новый сезон ' +
|
||||
'можно будет этой же кнопкой. ' +
|
||||
'Продолжить?',
|
||||
showConfirmButton: true,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Да',
|
||||
cancelButtonText: 'Нет'
|
||||
}).then(result => {
|
||||
if (result.isConfirmed) {
|
||||
adminRequest('closereg');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openReg() {
|
||||
Swal.fire({
|
||||
icon: 'question',
|
||||
title: 'Открытие регистрации',
|
||||
text:
|
||||
'Когда определились с турбазой и ценами, ' +
|
||||
'можно открывать регистрацию для принятия заявок. ' +
|
||||
'Желательно сначала указать информацию в разделе [Новый сезон]. ' +
|
||||
'Продолжить?',
|
||||
showConfirmButton: true,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Да',
|
||||
cancelButtonText: 'Нет'
|
||||
}).then(result => {
|
||||
if (result.isConfirmed) {
|
||||
adminRequest('openreg');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearReg() {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Очистка списка',
|
||||
text:
|
||||
'Очистить весь список зарегистрированных пользователей? ' +
|
||||
'Резервную копию можно сделать в разделе [Архивация].',
|
||||
showConfirmButton: true,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Да',
|
||||
cancelButtonText: 'Нет',
|
||||
focusCancel: true
|
||||
}).then(result => {
|
||||
if (result.isConfirmed) {
|
||||
adminRequest('clear');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function tableComputeCost() {
|
||||
|
||||
let block = document.querySelector('.price.hidden');
|
||||
let meal = block.querySelector('#meal').innerHTML;
|
||||
let child = block.querySelector('#child').innerHTML;
|
||||
let parent = block.querySelector('#parent').innerHTML;
|
||||
let shift = block.querySelector('#shift').innerHTML;
|
||||
|
||||
let tbody = document.querySelector('.regdb-wrapper table>tbody');
|
||||
let tr = tbody.querySelectorAll('tr:not(.caption)');
|
||||
|
||||
for (let row of tr) {
|
||||
(async (row) => {
|
||||
let code = row.dataset.code;
|
||||
let count = row.querySelector('td.count').dataset.value;
|
||||
let children = row.querySelector('td.children').dataset.value;
|
||||
let meals = row.querySelector('td.meal').dataset.value;
|
||||
let house = row.querySelector('td.house').dataset.value;
|
||||
let cost = row.querySelector('td.cost-cell');
|
||||
if (cost.dataset.value > 0) return;
|
||||
try {
|
||||
let res = await getCost(
|
||||
{ // prices
|
||||
meal: meal,
|
||||
child: child,
|
||||
parent: parent
|
||||
},
|
||||
{ // family data
|
||||
count: count,
|
||||
children: children,
|
||||
meals: meals,
|
||||
house: house
|
||||
},
|
||||
{ // additional data
|
||||
shift: shift,
|
||||
birthdays: [],
|
||||
code: code
|
||||
},
|
||||
been_last_year // view admin.html
|
||||
);
|
||||
cost.dataset.value = res[0];
|
||||
cost.querySelector('span.cost').innerText = res[0];
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
})(row);
|
||||
}
|
||||
}
|
||||
|
||||
function createItem(selector) {
|
||||
|
||||
let match = String(selector).match(/\-(\d+)$/);
|
||||
if (!match) return;
|
||||
let item = Number(match[1]);
|
||||
item++;
|
||||
|
||||
//
|
||||
let elem = document.querySelector(selector);
|
||||
if (!(elem instanceof HTMLElement)) return;
|
||||
|
||||
//
|
||||
let elem2 = elem.cloneNode(true);
|
||||
elem2.id = elem2.id.replace(/\d+/, item);
|
||||
|
||||
let lbl = elem2.querySelectorAll('label');
|
||||
lbl.forEach(el => {
|
||||
el.htmlFor = el.htmlFor.replace(/\d+/, item);
|
||||
});
|
||||
|
||||
let inp = elem2.querySelectorAll('input');
|
||||
inp.forEach(el => {
|
||||
el.id = el.id.replace(/\d+/, item);
|
||||
el.name = el.id.replace(/\d+/, item);
|
||||
// if it's not a CSRF token
|
||||
if (el.type != 'hidden')
|
||||
el.value = '';
|
||||
});
|
||||
|
||||
//
|
||||
let btn = elem.parentElement.parentElement.querySelector('input[type=button]');
|
||||
btn.onclick = () => createItem('#' + elem2.id);
|
||||
|
||||
//
|
||||
let num = item + 1;
|
||||
let shift_num = elem.previousElementSibling;
|
||||
let shift_num2 = shift_num.cloneNode(true);
|
||||
shift_num2.innerHTML = shift_num2.innerHTML.replace(/\d+/, num);
|
||||
|
||||
//
|
||||
elem.insertAdjacentElement('afterend', elem2);
|
||||
elem.insertAdjacentElement('afterend', shift_num2);
|
||||
}
|
||||
|
||||
function markAsDone(el) {
|
||||
familyRowRequest(
|
||||
el, 'payment',
|
||||
(_r, td) => {
|
||||
td.classList.toggle('done');
|
||||
td.dataset.value = (td.dataset.value == 1 ? 0 : 1);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function showParentsData(el) {
|
||||
familyRowRequest(
|
||||
el, 'parents', resp => {
|
||||
Swal.fire({
|
||||
html: resp,
|
||||
confirmButtonText: 'Закрыть'
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function showChildrenData(el) {
|
||||
familyRowRequest(
|
||||
el, 'children', resp => {
|
||||
Swal.fire({
|
||||
html: resp,
|
||||
confirmButtonText: 'Закрыть'
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function adminRequest(url, data, reload) {
|
||||
reload = (reload != undefined ? reload : true);
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/admin/' + url);
|
||||
if (reload) {
|
||||
xhr.onloadend = () => window.location.reload();
|
||||
}
|
||||
xhr.send(JSON.stringify({
|
||||
token: sessionCookie(),
|
||||
data: data
|
||||
}));
|
||||
}
|
||||
|
||||
function familyRowRequest(el, url, cb) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
cb = cb || function(_resp, _elem){};
|
||||
let td = el.parentElement;
|
||||
let row = td.parentElement;
|
||||
let family = row.id;
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/admin/' + url);
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState != xhr.DONE) return;
|
||||
if (xhr.response == null) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: `Ошибка ${xhr.status}`
|
||||
});
|
||||
}
|
||||
else
|
||||
cb(xhr.response, td);
|
||||
};
|
||||
xhr.send(JSON.stringify({
|
||||
token: sessionCookie(),
|
||||
mid: family,
|
||||
state: !td.classList.contains('done')
|
||||
}));
|
||||
}
|
||||
|
||||
function sessionCookie() {
|
||||
return document.cookie.match(session_regex)[1];
|
||||
}
|
116
lesa/static/js/cost.js
Normal file
116
lesa/static/js/cost.js
Normal file
|
@ -0,0 +1,116 @@
|
|||
async function getCost(price, data, adv, been_last_year) {
|
||||
|
||||
/*
|
||||
price: {meal, child, parent}
|
||||
data: {count, children, meals, house}
|
||||
adv: {shift, birthdays, code}
|
||||
been_last_year: ['123abc', '456cde']
|
||||
*/
|
||||
|
||||
// just multiply
|
||||
let adults = data.count - data.children;
|
||||
let house_cost = data.house * data.count;
|
||||
let meals = price.meal * data.meals;
|
||||
let camp_children = price.child * data.children;
|
||||
let camp_parents = price.parent * adults;
|
||||
|
||||
// discounts
|
||||
const third_child = 0.5; // 50%
|
||||
const last_year = 0.1; // 10%
|
||||
|
||||
let birthdays = adv.birthdays || [];
|
||||
let shift = Number(adv.shift);
|
||||
if (birthdays.constructor == Array) {
|
||||
|
||||
if (birthdays.length >= 3 &&
|
||||
birthdays.every(
|
||||
el => childAge(el,shift) > 3
|
||||
)
|
||||
) {
|
||||
// remove one child's camp activities cost
|
||||
camp_children -= price.child;
|
||||
// and add it back with discount
|
||||
camp_children += price.child - (price.child * third_child);
|
||||
}
|
||||
}
|
||||
|
||||
let camp = camp_parents + camp_children;
|
||||
let verified = false;
|
||||
|
||||
if (adv.code) {
|
||||
|
||||
adv.code = String(adv.code);
|
||||
|
||||
// if the list is provided
|
||||
// (e.g. this function was
|
||||
// executed from admin panel)
|
||||
if (been_last_year &&
|
||||
been_last_year.constructor == Array)
|
||||
{
|
||||
// check if it has the family code
|
||||
verified = been_last_year.includes(adv.code);
|
||||
}
|
||||
else {
|
||||
let req = await fetch('/form/code', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: String(adv.code)
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type':
|
||||
'application/json;charset=utf-8'
|
||||
}
|
||||
});
|
||||
let json = await req.json();
|
||||
verified = (json.data === true);
|
||||
}
|
||||
|
||||
if (verified) {
|
||||
// if the family was in the camp last year
|
||||
// (it is verified with the code),
|
||||
// give them a discount
|
||||
camp -= camp * last_year;
|
||||
}
|
||||
}
|
||||
|
||||
// result
|
||||
return [
|
||||
house_cost + meals + camp,
|
||||
verified
|
||||
];
|
||||
}
|
||||
|
||||
function childAge(bday, shift) {
|
||||
let diff = shift * 1000 - bday;
|
||||
let hours = diff / 1000 / 60 / 60;
|
||||
return hours / 24 / 365;
|
||||
}
|
||||
|
||||
async function getCode(family, phone) {
|
||||
|
||||
let code = null;
|
||||
if (!family) return {code: null, err: '11: Не указана фамилия семьи!'};
|
||||
if (!phone) return {code: null, err: '21: Не указан номер телефона!'};
|
||||
|
||||
// first 4 letters of surname
|
||||
// первые 4 буквы фамилии
|
||||
let letters = String(family).slice(0, 4);
|
||||
|
||||
let m = String(phone).match(/\d/g);
|
||||
if (!m) {
|
||||
return {
|
||||
code: null,
|
||||
err: '22: Не указан номер телефона!'
|
||||
}
|
||||
}
|
||||
|
||||
// -4 = four digits from the end
|
||||
// -4 = четыре цифры с конца
|
||||
last_digits = m.slice(-4).join('');
|
||||
|
||||
// Ивановы, 912 345 6789 =
|
||||
// 6789Иван
|
||||
code = last_digits + letters;
|
||||
|
||||
return {code: code};
|
||||
}
|
35
lesa/static/js/dark.js
Normal file
35
lesa/static/js/dark.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
addEventListener('DOMContentLoaded', () => {
|
||||
if (window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
toggleDark(false, true);
|
||||
getCookieDark();
|
||||
});
|
||||
|
||||
function toggleDark(btn, value) {
|
||||
|
||||
// if value was not set,
|
||||
// just toggle dark mode
|
||||
if (value == undefined)
|
||||
document.body.classList.toggle('dark');
|
||||
else
|
||||
if (value)
|
||||
document.body.classList.add('dark');
|
||||
else
|
||||
document.body.classList.remove('dark');
|
||||
|
||||
// if the function was called by onclick
|
||||
if (btn)
|
||||
setCookieDark();
|
||||
}
|
||||
|
||||
function getCookieDark() {
|
||||
let raw = document.cookie;
|
||||
let list = raw.split(/; ?/);
|
||||
if (list.includes('lesa_dark=true'))
|
||||
toggleDark(true);
|
||||
}
|
||||
|
||||
function setCookieDark() {
|
||||
let state = document.body.classList.contains('dark');
|
||||
document.cookie = 'lesa_dark=' + state + ';path=/';
|
||||
}
|
229
lesa/static/js/editor.js
Normal file
229
lesa/static/js/editor.js
Normal file
|
@ -0,0 +1,229 @@
|
|||
const edit = {
|
||||
selection: (textarea) => {
|
||||
let txt = textarea.value;
|
||||
let before = txt.slice(0, textarea.selectionStart);
|
||||
let selected = txt.substring(
|
||||
textarea.selectionStart,
|
||||
textarea.selectionEnd
|
||||
);
|
||||
let after = txt.slice(textarea.selectionEnd);
|
||||
return {
|
||||
before: before,
|
||||
after: after,
|
||||
content: selected,
|
||||
length: selected.length
|
||||
};
|
||||
},
|
||||
textarea: (elem) => {
|
||||
if (!(elem instanceof HTMLElement)) return;
|
||||
let toolbar = elem.parentElement.parentElement;
|
||||
id = toolbar.dataset.textarea;
|
||||
return document.getElementById(id);
|
||||
},
|
||||
insert: (elem, code) => {
|
||||
let textarea;
|
||||
if (elem.constructor == String)
|
||||
textarea = document.getElementById(elem);
|
||||
else
|
||||
textarea = edit.textarea(elem);
|
||||
let sel = edit.selection(textarea);
|
||||
let repl;
|
||||
if (code.constructor == Array)
|
||||
// 0 = beginning tag, 1 = closing tag
|
||||
repl = code[0] + sel.content + code[1];
|
||||
else
|
||||
// replacement for whole selection
|
||||
repl = code;
|
||||
textarea.value = sel.before + repl + sel.after;
|
||||
},
|
||||
bold: (elem) => {
|
||||
edit.insert(elem, ['[ж]','[/ж]']);
|
||||
},
|
||||
italic: (elem) => {
|
||||
edit.insert(elem, ['[к]','[/к]']);
|
||||
},
|
||||
underlined: (elem) => {
|
||||
edit.insert(elem, ['[ч]','[/ч]']);
|
||||
},
|
||||
strikeouted: (elem) => {
|
||||
edit.insert(elem, ['[з]','[/з]']);
|
||||
},
|
||||
linebreak: (elem) => {
|
||||
edit.insert(elem, ['+п\n','']);
|
||||
},
|
||||
heading: async (elem) => {
|
||||
let textarea = edit.textarea(elem);
|
||||
const title = 'Добавить подзаголовок';
|
||||
const def = edit.selection(textarea).content;
|
||||
|
||||
let { value: size } = await Swal.fire({
|
||||
title: title,
|
||||
html: '<span id="example"></span>',
|
||||
showConfirmButton: true,
|
||||
showCancelButton: true,
|
||||
cancelButtonText: 'Отмена',
|
||||
input: 'range',
|
||||
inputLabel: 'Выберите размер подзаголовка',
|
||||
inputValue: 3,
|
||||
inputAttributes: {
|
||||
min: 1,
|
||||
max: 6,
|
||||
step: 1
|
||||
},
|
||||
didOpen: () => {
|
||||
|
||||
let html = Swal.getHtmlContainer();
|
||||
let ex = html.querySelector('#example');
|
||||
let inp = Swal.getInput();
|
||||
function updateExample(val) {
|
||||
val = Number(val);
|
||||
if (!val) return;
|
||||
ex.innerHTML = `<h${val}>Пример</h${val}>`;
|
||||
}
|
||||
updateExample(inp.value);
|
||||
|
||||
inp.addEventListener('input', (ev) => {
|
||||
updateExample(ev.target.value);
|
||||
});
|
||||
}
|
||||
});
|
||||
if (!size) return;
|
||||
|
||||
let { value: txt } = await Swal.fire({
|
||||
title: title,
|
||||
showConfirmButton: true,
|
||||
showCancelButton: true,
|
||||
cancelButtonText: 'Отмена',
|
||||
input: 'text',
|
||||
inputPlaceholder: 'Введите текст',
|
||||
inputValue: def
|
||||
});
|
||||
if (!txt) return;
|
||||
|
||||
edit.insert(elem, `[под${size}]${txt}[/под]`);
|
||||
},
|
||||
link: async (elem) => {
|
||||
let textarea = edit.textarea(elem);
|
||||
const title = 'Добавить ссылку';
|
||||
const def = edit.selection(textarea).content;
|
||||
|
||||
let { value: url } = await Swal.fire({
|
||||
title: title,
|
||||
showConfirmButton: true,
|
||||
showCancelButton: true,
|
||||
input: 'url',
|
||||
inputPlaceholder: 'Введите URL-адрес'
|
||||
});
|
||||
if (!url) return;
|
||||
|
||||
let { value: txt } = await Swal.fire({
|
||||
title: title,
|
||||
showConfirmButton: true,
|
||||
showCancelButton: true,
|
||||
input: 'text',
|
||||
inputPlaceholder: 'Введите текст',
|
||||
inputValue: def
|
||||
});
|
||||
|
||||
txt = txt || '';
|
||||
let code = `[сс:${txt}]${url}[/сс]`;
|
||||
edit.insert(elem, code);
|
||||
},
|
||||
list: async (elem) => {
|
||||
const title = 'Добавить список';
|
||||
let { value: marker } = await Swal.fire({
|
||||
title: title,
|
||||
showConfirmButton: true,
|
||||
showCancelButton: true,
|
||||
input: 'select',
|
||||
inputPlaceholder: 'Выберите тип списка',
|
||||
inputOptions: {
|
||||
'-': '- Дефис',
|
||||
'*': '* Звёздочка',
|
||||
disc: '\u2022 Закрашенный круг',
|
||||
circle: '\u25e6 Незакрашенный круг',
|
||||
square: '\u25a0 Квадрат',
|
||||
decimal: '1. Нумерованный',
|
||||
'decimal-leading-zero': '01. Нумерованный (с нулём в начале)',
|
||||
'lower-roman': 'i. Маленькие римские цифры',
|
||||
'upper-roman': 'I. Большие римские цифры',
|
||||
'lower-alpha': 'a. Маленькие латинские буквы',
|
||||
'upper-alpha': 'A. Большие латинские буквы',
|
||||
'lower-greek': '\u03b1. Маленькие греческие буквы'
|
||||
}
|
||||
});
|
||||
if (!marker) return;
|
||||
|
||||
let i = 1;
|
||||
let list_items = [];
|
||||
while (true) {
|
||||
|
||||
let one = i % 10; // разряд единиц
|
||||
let ten = i % 100; // разряд десятков
|
||||
let ending = 'ый';
|
||||
// 2ой
|
||||
if (one == 2 && ten != 1)
|
||||
ending = 'ой';
|
||||
// 3ий
|
||||
if (one == 3 && ten != 1)
|
||||
ending = 'ий';
|
||||
// 6ой, 7ой, 8ой
|
||||
if (one >= 6 && one <= 8 && ten != 1)
|
||||
ending = 'ой';
|
||||
|
||||
let { value: item } = await Swal.fire({
|
||||
title: title,
|
||||
showConfirmButton: true,
|
||||
showCancelButton: true,
|
||||
input: 'text',
|
||||
inputPlaceholder:
|
||||
`Введите ${i}-${ending} элемент списка`
|
||||
});
|
||||
if (!item) break;
|
||||
list_items.push(item);
|
||||
i++;
|
||||
}
|
||||
|
||||
let code = `[сп:${marker}]`;
|
||||
for (let item of list_items) {
|
||||
code += `[-]${item}[/-]`;
|
||||
}
|
||||
code += '[/сп]';
|
||||
edit.insert(elem, code);
|
||||
},
|
||||
picture: async (elem) => {
|
||||
|
||||
let filenames = {};
|
||||
|
||||
let textarea = edit.textarea(elem);
|
||||
let form = textarea.parentElement;
|
||||
let photos = form.querySelectorAll('.photos>.photo>a');
|
||||
if (!photos.length < 1) {
|
||||
for (let p of photos) {
|
||||
data = p.dataset.photo;
|
||||
filenames[data] = data;
|
||||
}
|
||||
}
|
||||
|
||||
let upload = form.querySelector('#photos');
|
||||
if (upload &&
|
||||
upload instanceof HTMLInputElement &&
|
||||
upload.type == 'file')
|
||||
{
|
||||
for (let f of upload.files) {
|
||||
filenames[f.name] = f.name;
|
||||
}
|
||||
}
|
||||
|
||||
let { value: file } = await Swal.fire({
|
||||
title: 'Добавить картинку',
|
||||
showConfirmButton: true,
|
||||
showCancelButton: true,
|
||||
input: 'select',
|
||||
inputPlaceholder: 'Выберите загруженный файл',
|
||||
inputOptions: filenames
|
||||
});
|
||||
if (!file) return;
|
||||
edit.insert(elem, ['',`[кар]${file}[/кар]`]);
|
||||
}
|
||||
};
|
162
lesa/static/js/filter.js
Normal file
162
lesa/static/js/filter.js
Normal file
|
@ -0,0 +1,162 @@
|
|||
var tbody = document.querySelector('.regdb-wrapper table>tbody');
|
||||
const rows_selector = 'tr:not(.caption)';
|
||||
|
||||
var tr = Array.from(tbody.querySelectorAll(rows_selector));
|
||||
var tr_len = tr.length;
|
||||
|
||||
function columnContextMenu(ev) {
|
||||
if (!(ev instanceof MouseEvent)) return;
|
||||
hideContextMenu();
|
||||
let th = ev.currentTarget.parentElement;
|
||||
let menu = th.querySelector('ul.context-menu');
|
||||
menu.classList.add('open');
|
||||
menu.style.top = ev.pageY + 5 + 'px';
|
||||
menu.style.left = ev.pageX + 5 + 'px';
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
function filter(col, elem) {
|
||||
|
||||
hideContextMenu();
|
||||
if (!(elem instanceof HTMLAnchorElement)) return;
|
||||
col = Number(col) || 0;
|
||||
|
||||
let menu = elem.parentElement.parentElement;
|
||||
|
||||
let start = performance.now();
|
||||
|
||||
let search = menu.querySelector('input').value;
|
||||
let rows = searchText(col, search.trim());
|
||||
|
||||
let is_asc = menu.querySelector('select').value == 'asc';
|
||||
rows = filterBy(col, is_asc, rows);
|
||||
|
||||
let end = performance.now();
|
||||
console.log('Filtering done in ' + (end - start));
|
||||
|
||||
displayFiltered(rows);
|
||||
let end2 = performance.now();
|
||||
console.log('Rendering done in ' + (end2 - start));
|
||||
}
|
||||
|
||||
function searchText(col, query, rows_arr) {
|
||||
|
||||
// retrieving the rows list
|
||||
// получаем список строк
|
||||
rows_arr = rows_arr || tr;
|
||||
let rows_len = rows_arr.length;
|
||||
|
||||
// if the query is not empty
|
||||
// если запрос не пустой
|
||||
if (query.trim() != '') {
|
||||
|
||||
// searching the rows using css selector
|
||||
// ищем строки с помощью css-селектора
|
||||
matching = tbody.querySelectorAll(
|
||||
`${rows_selector}>td[data-value*="${query}"]:nth-child(${col + 1})`
|
||||
);
|
||||
let len = matching.length;
|
||||
let rows = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
rows.push(matching[i].parentElement);
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows_len; i++) {
|
||||
let elem = rows_arr[i];
|
||||
if (rows.indexOf(elem) > -1)
|
||||
elem.style.display = 'table-row';
|
||||
else
|
||||
elem.style.display = 'none';
|
||||
}
|
||||
}
|
||||
else {
|
||||
// or just show all rows
|
||||
// либо просто показываем все строки
|
||||
for (let i = 0; i < rows_len; i++) {
|
||||
rows_arr[i].style.display = 'table-row';
|
||||
}
|
||||
}
|
||||
|
||||
return rows_arr;
|
||||
}
|
||||
|
||||
function search(col, query, elem) {
|
||||
const cell_data = columnDataOne(col, elem);
|
||||
return cell_data.includes(query);
|
||||
}
|
||||
|
||||
function filterBy(col, asc, rows_arr) {
|
||||
|
||||
// retrieving the rows list
|
||||
// получаем список строк
|
||||
rows_arr = rows_arr || tr;
|
||||
|
||||
// if asc, func=ascSort, else func=descSort
|
||||
let func = (
|
||||
asc ?
|
||||
(a,b) => ascSort(col,a,b) :
|
||||
(a,b) => descSort(col,a,b)
|
||||
);
|
||||
// sorting
|
||||
rows_arr.sort(func);
|
||||
return rows_arr;
|
||||
}
|
||||
|
||||
function displayFiltered(rows) {
|
||||
|
||||
// replacing the table content
|
||||
// меняем содержимое таблицы
|
||||
|
||||
// clearing
|
||||
// очистка
|
||||
for (let i = 0; i < tr_len; i++) {
|
||||
(async i => tr[i].remove())(i);
|
||||
}
|
||||
|
||||
// filling
|
||||
// заполнение
|
||||
let rows_len = rows.length;
|
||||
for (let i = 0; i < rows_len; i++) {
|
||||
(async i => tbody.append(rows[i]))(i);
|
||||
}
|
||||
}
|
||||
|
||||
// descending
|
||||
// от большего к меньшему
|
||||
function descSort(col, a, b) {
|
||||
// распаковка
|
||||
const [a_val, b_val] = columnData(col, a, b);
|
||||
// -1 = a идёт первым
|
||||
// 1 = b идёт первым
|
||||
if (a_val < b_val) return 1;
|
||||
if (a_val > b_val) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ascending
|
||||
// от меньшего к большему
|
||||
function ascSort(col, a, b) {
|
||||
// распаковка
|
||||
const [a_val, b_val] = columnData(col, a, b);
|
||||
// -1 = a идёт первым
|
||||
// 1 = b идёт первым
|
||||
if (a_val < b_val) return -1;
|
||||
if (a_val > b_val) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// extracting data from columns
|
||||
// извлечение данных из столбцов
|
||||
function columnData(col, a, b) {
|
||||
const a_col = a.querySelectorAll('td')[col].dataset;
|
||||
const b_col = b.querySelectorAll('td')[col].dataset;
|
||||
const a_val = (a_col.type == 'num' ? Number(a_col.value) : a_col.value);
|
||||
const b_val = (b_col.type == 'num' ? Number(b_col.value) : b_col.value);
|
||||
return [a_val, b_val];
|
||||
}
|
||||
|
||||
function columnDataOne(col, a) {
|
||||
const a_col = a.querySelectorAll('td')[col].dataset;
|
||||
return a_col.value;
|
||||
}
|
46
lesa/static/js/loader.js
Normal file
46
lesa/static/js/loader.js
Normal file
|
@ -0,0 +1,46 @@
|
|||
// Content Loader
|
||||
|
||||
var addr = '';
|
||||
|
||||
function getPage(name) {
|
||||
addr = name;
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/page/' + name);
|
||||
xhr.onreadystatechange = () => {
|
||||
// if loading
|
||||
if (xhr.readyState != xhr.DONE)
|
||||
return
|
||||
// if completed
|
||||
// show response
|
||||
if (xhr.status == 200) {
|
||||
showContent(xhr.response);
|
||||
return
|
||||
}
|
||||
// or notify about error
|
||||
xhrError(xhr.status, xhr.statusText);
|
||||
}
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function showContent(html) {
|
||||
var block = document.querySelector('.content-wrapper');
|
||||
let page = html.replace(
|
||||
/<div class="timestamp">(\d+)(?:\.\d+)?/g,
|
||||
(_match, p1) => {
|
||||
let dt = new Date(p1 * 1000);
|
||||
let str = dt.toLocaleString(['ru'])
|
||||
return '<div class="timestamp">' + str;
|
||||
}
|
||||
);
|
||||
page = markup(page);
|
||||
block.innerHTML = page;
|
||||
if (addr == 'register') regInit();
|
||||
addr = '';
|
||||
}
|
||||
|
||||
function xhrError(code, err) {
|
||||
console.log(
|
||||
'Unable to perform an XHR request!\n' +
|
||||
`Status: ${code} ${err}`
|
||||
);
|
||||
}
|
79
lesa/static/js/markup.js
Normal file
79
lesa/static/js/markup.js
Normal file
|
@ -0,0 +1,79 @@
|
|||
function markup(html) {
|
||||
|
||||
let photos = '';
|
||||
let thumbs = '';
|
||||
html = html.replace(
|
||||
/photos=(.*?)\$!/g,
|
||||
(_m, p1) => {
|
||||
photos = p1;
|
||||
return '';
|
||||
}
|
||||
);
|
||||
html = html.replace(
|
||||
/thumbs=(.*?)\$!/g,
|
||||
(_m, p1) => {
|
||||
thumbs = p1;
|
||||
return '';
|
||||
}
|
||||
);
|
||||
|
||||
let tags = {
|
||||
'[ж](.*?)[/ж]': '<b>$1</b>',
|
||||
'[к](.*?)[/к]': '<i>$1</i>',
|
||||
'[ч](.*?)[/ч]': '<u>$1</u>',
|
||||
'[з](.*?)[/з]': '<s>$1</s>',
|
||||
'[под(\\d)](.*?)[/под(?:\\d)?]': '<h$1>$2</h$1>',
|
||||
'[сс:?(.*?)](.*?)[/сс]': (_m, p1, p2) => {
|
||||
let url = p2;
|
||||
let text = p1;
|
||||
if (!p1) {
|
||||
url = p2;
|
||||
text = String(p2).slice(0, 48);
|
||||
}
|
||||
return `<a href="${url.trim()}">${text.trim()}</a>`;
|
||||
},
|
||||
'[сп:?(.*?)](.*?)[/сп]': (_m, p1, p2) => {
|
||||
let content = p2;
|
||||
let marker = 'disc';
|
||||
|
||||
if (p1 == '1')
|
||||
return `<ol>${content.trim()}</ol>`;
|
||||
|
||||
if (p1)
|
||||
marker = p1.trim();
|
||||
|
||||
return `<ul style="list-style-type:${marker};">${content}</ul>`;
|
||||
},
|
||||
'[-](.*?)[/-]': '<li>$1</li>',
|
||||
'[кар](.*?)[/кар]':
|
||||
`<div class="photos" data-dir="${photos}">` +
|
||||
`<div class="photo">` +
|
||||
`<a href="javascript:void(0);" onclick="showPhoto(this);" data-photo="$1">` +
|
||||
`<img src="${thumbs}/$1" />` +
|
||||
`</a>` +
|
||||
`</div>` +
|
||||
`</div>`,
|
||||
'\\+п(?:ере)?': '<br>'
|
||||
};
|
||||
|
||||
return html.replace(
|
||||
/<div class="markup">([\s\S]+?)<\/div>/g,
|
||||
(_match, p1) => {
|
||||
|
||||
let rendered = String(p1);
|
||||
for (let key in tags) {
|
||||
|
||||
let repl = tags[key];
|
||||
let regex = key
|
||||
.replace(/\[/g, '\\[')
|
||||
.replace(/\]/g, '\\]');
|
||||
|
||||
rendered = rendered.replace(
|
||||
new RegExp(regex, 'gs'),
|
||||
repl
|
||||
);
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
).trim();
|
||||
}
|
28
lesa/static/js/photos.js
Normal file
28
lesa/static/js/photos.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
function showPhoto(elem) {
|
||||
if (!(elem instanceof HTMLAnchorElement)) return;
|
||||
let parts = getPhotoUrl(elem);
|
||||
let file = parts[1];
|
||||
let url = parts.join('/');
|
||||
Swal.fire({
|
||||
text: file,
|
||||
imageUrl: url,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Увеличить',
|
||||
cancelButtonText: 'Закрыть',
|
||||
heightAuto: false
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
let link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getPhotoUrl(elem) {
|
||||
let photos = elem.parentElement.parentElement;
|
||||
let dir = photos.dataset.dir;
|
||||
let file = elem.dataset.photo;
|
||||
return [dir, file];
|
||||
}
|
476
lesa/static/js/register.js
Normal file
476
lesa/static/js/register.js
Normal file
|
@ -0,0 +1,476 @@
|
|||
// Interactive Forms and XHR
|
||||
|
||||
var page = 0;
|
||||
var prev = false;
|
||||
var forms = [];
|
||||
var sendbtn = null;
|
||||
var inprogress = false;
|
||||
var jsform;
|
||||
|
||||
function regInit() {
|
||||
|
||||
// blank forms
|
||||
jsform = {
|
||||
parent: document.querySelector('#parent-form>.fields'),
|
||||
child: document.querySelector('#child-form>.fields')
|
||||
};
|
||||
// they are removed from HTML,
|
||||
// but not from JS objects
|
||||
// and still can be accessed
|
||||
// with jsform.parent,child
|
||||
// (view update*Fields functions)
|
||||
//
|
||||
// они удаляются из HTML,
|
||||
// но не из объектов в JavaScript,
|
||||
// и всё ещё доступны через jsform.parent,child
|
||||
// (см. функции update*Fields)
|
||||
jsform.parent.remove();
|
||||
jsform.child.remove();
|
||||
|
||||
// initialize
|
||||
let cb = document.querySelector('#f1 input[type=checkbox]');
|
||||
cb.checked = true;
|
||||
updateParentsFields(cb);
|
||||
updateChildrenFields(1);
|
||||
changePage();
|
||||
|
||||
document.querySelectorAll('ul.hide-items>li').forEach(
|
||||
elem => elem.classList.add('hidden')
|
||||
);
|
||||
}
|
||||
|
||||
// ***
|
||||
// Simple Forms Page Switcher
|
||||
|
||||
function regNext() {
|
||||
page++;
|
||||
changePage();
|
||||
}
|
||||
|
||||
function regPrev() {
|
||||
page--;
|
||||
prev = true;
|
||||
changePage();
|
||||
prev = false;
|
||||
}
|
||||
|
||||
function changePage() {
|
||||
|
||||
// refresh
|
||||
forms = document.querySelectorAll('div.form');
|
||||
btns = document.querySelectorAll('footer>.button');
|
||||
last = forms.length - 1;
|
||||
|
||||
// check
|
||||
page = Math.max(page, 0);
|
||||
page = Math.min(page, last);
|
||||
|
||||
// hide all forms
|
||||
// excluding id=f<page>
|
||||
for (var i = 0; i < forms.length; i++) {
|
||||
if (forms[i] == forms[page]) {
|
||||
forms[i].style.display = 'block';
|
||||
continue;
|
||||
}
|
||||
forms[i].style.display = 'none';
|
||||
}
|
||||
|
||||
// change buttons state
|
||||
// (active / inactive)
|
||||
if (page == 0) { btns[0].classList.add('inactive'); }
|
||||
if (page != 0) { btns[0].classList.remove('inactive'); }
|
||||
if (page == last) { btns[1].classList.add('inactive'); }
|
||||
if (page != last) { btns[1].classList.remove('inactive'); }
|
||||
|
||||
// progressbar
|
||||
document.body.style.setProperty('--pbwidth', `${100 / (forms.length - 1) * page}%`);
|
||||
}
|
||||
|
||||
function computeCost(cb, phone) {
|
||||
|
||||
// how many people
|
||||
let count_elem = document.querySelector('input#count');
|
||||
let count = Number(count_elem.value) || 2;
|
||||
let children_elem = document.querySelector('input#children');
|
||||
let children = Number(children_elem.value) || 1;
|
||||
let meals_elem = document.querySelector('input#meal_count');
|
||||
let meals_count = Number(meals_elem.value) || count;
|
||||
|
||||
// what type of house and its price
|
||||
let house = Array.from(
|
||||
document.querySelectorAll(
|
||||
'ul#house input[type=radio]'
|
||||
)
|
||||
).filter(elem => elem.checked)[0];
|
||||
let selected = (house ? house.value : 0);
|
||||
|
||||
// camp activities and meals price
|
||||
let block = document.querySelector('div.price');
|
||||
let meal = block.querySelector('#meal').innerHTML;
|
||||
let child = block.querySelector('#child').innerHTML;
|
||||
let parent = block.querySelector('#parent').innerHTML;
|
||||
|
||||
// the 1st shift date and children's birthdays
|
||||
let shift = block.querySelector('#shift').innerHTML;
|
||||
|
||||
// span where must be displayed the cost
|
||||
let field = document.querySelector('span.cost>span.number');
|
||||
|
||||
(async () => {
|
||||
|
||||
field.classList.add('loading');
|
||||
|
||||
// children's birthdays
|
||||
let birthdays = document.querySelectorAll('div.form.child input[type=date][name$=-birthday]');
|
||||
let children_bday = []; // date as a timestamp
|
||||
birthdays.forEach(elem => {
|
||||
if (!(elem instanceof HTMLInputElement && elem.type == 'date')) return;
|
||||
children_bday.push(elem.valueAsNumber);
|
||||
});
|
||||
|
||||
// compute and display
|
||||
let code = (cb ? await genCode(cb, phone) : [null])
|
||||
let result = await getCost(
|
||||
{ // prices
|
||||
meal: meal,
|
||||
child: child,
|
||||
parent: parent
|
||||
},
|
||||
{ // family data
|
||||
count: count,
|
||||
children: children,
|
||||
house: selected,
|
||||
meals: meals_count
|
||||
},
|
||||
{ // additional data
|
||||
shift: shift,
|
||||
birthdays: children_bday,
|
||||
code: code[0]
|
||||
}
|
||||
);
|
||||
field.innerHTML = result[0];
|
||||
|
||||
if (cb) {
|
||||
let was_checked = cb.checked;
|
||||
cb.checked = result[1];
|
||||
// if not errored and cb was checked
|
||||
if (!code[1] && was_checked)
|
||||
processVerificationResult(result[1]);
|
||||
}
|
||||
|
||||
if (result[1]) {
|
||||
document.getElementById('code').value = code[0];
|
||||
}
|
||||
|
||||
field.classList.remove('loading');
|
||||
|
||||
})();
|
||||
}
|
||||
|
||||
async function genCode(cb, phone) {
|
||||
|
||||
if (!(cb instanceof HTMLInputElement)) return;
|
||||
|
||||
if (cb.checked || phone) {
|
||||
|
||||
if (!phone) {
|
||||
|
||||
let main_phone = Array.from(
|
||||
document.querySelectorAll(
|
||||
'ul#main_phone input[type=radio]'
|
||||
)
|
||||
).filter(elem => elem.checked)[0];
|
||||
|
||||
phone = (
|
||||
main_phone ?
|
||||
main_phone.value :
|
||||
document.querySelector(
|
||||
'input[name$=-phone]'
|
||||
).value
|
||||
);
|
||||
}
|
||||
|
||||
let family = document.getElementById('family').value;
|
||||
const {code, err} = await getCode(family, phone);
|
||||
|
||||
if (err) {
|
||||
await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: String(err).replace(/^\d+?:/, ''),
|
||||
showConfirmButton: true,
|
||||
heightAuto: false
|
||||
});
|
||||
cb.checked = false;
|
||||
return [null, true];
|
||||
}
|
||||
return [code, false];
|
||||
}
|
||||
|
||||
return [null];
|
||||
}
|
||||
|
||||
function processVerificationResult(state) {
|
||||
|
||||
if (Boolean(state)) return;
|
||||
|
||||
const code_err =
|
||||
'Для проверки, что Ваша семья была ' +
|
||||
'в лагере в прошлом году используется код, ' +
|
||||
'состоящий из 4 цифр номера телефона и 4 букв фамилии. ' +
|
||||
'Ваш код не удалось найти в базе. ' +
|
||||
'Возможно, Вы поменяли номер телефона. ' +
|
||||
'Введите здесь старый номер, либо свяжитесь с организатором.';
|
||||
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Не удалось найти код!',
|
||||
text: code_err,
|
||||
showConfirmButton: true,
|
||||
showCancelButton: true,
|
||||
cancelButtonText: 'Отмена',
|
||||
input: 'text',
|
||||
inputPlaceholder: 'Старый номер телефона',
|
||||
heightAuto: false
|
||||
}).then(result => {
|
||||
if (result.isConfirmed) {
|
||||
let cb = document.getElementById('been-last-year');
|
||||
cb.checked = true;
|
||||
computeCost(cb, result.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateParentsFields(cb) {
|
||||
|
||||
if (!(cb instanceof HTMLInputElement)) return;
|
||||
|
||||
let form = cb.parentElement.parentElement.parentElement;
|
||||
// if the parent is not going to the camp
|
||||
if (!cb.checked) {
|
||||
form.querySelector('.fields').remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise
|
||||
let page_str = form.id.replace('f', '');
|
||||
let page = Number(page_str);
|
||||
let field = page - 1;
|
||||
|
||||
form.append(createItem(jsform.parent, field));
|
||||
}
|
||||
|
||||
function updateChildrenFields(count) {
|
||||
|
||||
const PAGES_BEFORE_CHILDREN = 2;
|
||||
|
||||
if (count == '') return;
|
||||
|
||||
count = Number(count) || 1;
|
||||
count = Math.min(count, 10);
|
||||
let children = document.querySelectorAll('div.form.child');
|
||||
let diff = count - children.length;
|
||||
|
||||
let parents = document.querySelectorAll('div.form.parent');
|
||||
let lastparent = parents[parents.length-1];
|
||||
|
||||
let lastchild = children[children.length-1];
|
||||
let lastid;
|
||||
if (lastchild)
|
||||
lastid = lastchild.id.replace('f', '');
|
||||
lastid = Number(lastid) || PAGES_BEFORE_CHILDREN;
|
||||
|
||||
if (diff > 0) {
|
||||
for (var i = 0; i < diff; i++) {
|
||||
|
||||
let field = lastid - PAGES_BEFORE_CHILDREN + i;
|
||||
let pagenum = lastid + 1;
|
||||
|
||||
let newpage = document.createElement('div');
|
||||
newpage.classList.add('form');
|
||||
newpage.classList.add('child');
|
||||
newpage.id = 'f' + pagenum;
|
||||
|
||||
newpage.append(createItem(jsform.child, field));
|
||||
|
||||
let prev_elem = lastchild || lastparent;
|
||||
prev_elem.insertAdjacentElement('afterend', newpage);
|
||||
|
||||
lastchild = newpage;
|
||||
}
|
||||
// reload to automatically set
|
||||
// display:none for new pages
|
||||
changePage();
|
||||
}
|
||||
else if (diff < 0) {
|
||||
Array.from(children)
|
||||
.slice(children.length + diff)
|
||||
.forEach(elem => elem.remove());
|
||||
}
|
||||
}
|
||||
|
||||
function createItem(baseform, num) {
|
||||
let newform = baseform.cloneNode(true);
|
||||
newform.id = newform.id.replace(/\d+/, num);
|
||||
|
||||
let lbl = newform.querySelectorAll('label');
|
||||
lbl.forEach(elem => {
|
||||
elem.htmlFor = elem.htmlFor.replace(/\d+/, num);
|
||||
});
|
||||
|
||||
let inp = newform.querySelectorAll('input');
|
||||
inp.forEach(elem => {
|
||||
elem.id = elem.id.replace(/\d+/, num);
|
||||
elem.name = elem.name.replace(/\d+/, num);
|
||||
});
|
||||
|
||||
return newform;
|
||||
}
|
||||
|
||||
function changeMainEmail() {
|
||||
changeChoices('email');
|
||||
}
|
||||
|
||||
function changeMainPhone() {
|
||||
changeChoices('phone');
|
||||
}
|
||||
|
||||
function changeChoices(field) {
|
||||
|
||||
const no_data = 'Не указано';
|
||||
let fields = document.querySelectorAll(`input[id$=-${field}]`);
|
||||
let list = document.querySelectorAll(`#main_${field}>li`);
|
||||
let radio = document.querySelectorAll(`#main_${field}>li>input`);
|
||||
try {
|
||||
|
||||
let val1 = fields[0].value.trim() || no_data;
|
||||
list[0].classList.remove('hidden');
|
||||
radio[0].value = val1;
|
||||
radio[0].labels[0].innerText = val1;
|
||||
|
||||
if (fields.length > 1) {
|
||||
|
||||
let val2 = fields[1].value.trim() || no_data;
|
||||
list[1].classList.remove('hidden');
|
||||
radio[1].value = val2;
|
||||
radio[1].labels[0].innerText = val2;
|
||||
return;
|
||||
}
|
||||
|
||||
list[1].classList.add('hidden');
|
||||
[radio[1].checked, radio[0].checked] = [false, true];
|
||||
}
|
||||
catch (_err) {}
|
||||
}
|
||||
|
||||
// ***
|
||||
// Sending forms data
|
||||
|
||||
function regSend() {
|
||||
|
||||
// if a user has already sent the data
|
||||
if (inprogress) return;
|
||||
|
||||
//
|
||||
|
||||
let regform = document.querySelector('form');
|
||||
sendbtn = document.querySelector('a#send');
|
||||
|
||||
// send form
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/form/register');
|
||||
xhr.responseType = 'json';
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState != xhr.DONE) return;
|
||||
|
||||
if (xhr.status == 500) {
|
||||
error('Произошла ошибка на сервере');
|
||||
return;
|
||||
}
|
||||
|
||||
if (xhr.status != 200) {
|
||||
error(`Ошибка ${xhr.status} ${xhr.statusText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!xhr.response.ok) {
|
||||
error(xhr.response.data);
|
||||
return;
|
||||
}
|
||||
|
||||
end();
|
||||
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Готово!',
|
||||
timer: 2000,
|
||||
heightAuto: false
|
||||
});
|
||||
|
||||
// switch to the main page
|
||||
window.location.hash = '';
|
||||
};
|
||||
xhr.send(new FormData(regform));
|
||||
|
||||
// change text
|
||||
sendbtn.innerHTML = 'Отправка...';
|
||||
inprogress = true;
|
||||
}
|
||||
|
||||
function error(data) {
|
||||
|
||||
end();
|
||||
|
||||
const csrf_err =
|
||||
'Похоже, сервер перезапустился ' +
|
||||
'(а это должно быть очень редко),\n' +
|
||||
'из-за чего поменялся код безопасности.\n' +
|
||||
'Вам нужно будет заполнить форму заново.';
|
||||
|
||||
// if it's a list of wtforms errors
|
||||
if (data && data.constructor == Array) {
|
||||
|
||||
let fields = data.join(', ').toLowerCase();
|
||||
|
||||
console.log(fields);
|
||||
console.log(fields.includes('csrf token'));
|
||||
console.log(fields.indexOf('csrf token'));
|
||||
|
||||
// if the server was restarted
|
||||
if (fields.includes('csrf token')) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ошибка CSRF_TOKEN',
|
||||
text: csrf_err,
|
||||
heightAuto: false
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed)
|
||||
window.location.reload();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// else, just show which fields are invalid
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Неверно заполнены:',
|
||||
text: fields,
|
||||
heightAuto: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise,
|
||||
// show an error message
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: data,
|
||||
heightAuto: false
|
||||
});
|
||||
}
|
||||
|
||||
function end() {
|
||||
inprogress = false;
|
||||
sendbtn.innerHTML = 'Отправить';
|
||||
page = 0;
|
||||
changePage();
|
||||
}
|
313
lesa/static/js/register.js.old
Normal file
313
lesa/static/js/register.js.old
Normal file
|
@ -0,0 +1,313 @@
|
|||
// Interactive Forms and XHR
|
||||
|
||||
var page = 0;
|
||||
var prev = false;
|
||||
var forms = [];
|
||||
var skip = [0];
|
||||
var sendbtn = null;
|
||||
var inprogress = false;
|
||||
var atleast_onechild = false;
|
||||
|
||||
// ***
|
||||
// Simple Forms Page Switcher
|
||||
|
||||
function regInit() {
|
||||
setSkip();
|
||||
changePage();
|
||||
};
|
||||
|
||||
function changePage() {
|
||||
|
||||
// refresh
|
||||
forms = document.querySelectorAll('form');
|
||||
btns = document.querySelectorAll('footer>.button');
|
||||
last = forms.length - 1;
|
||||
|
||||
// check
|
||||
page = Math.max(page, 0);
|
||||
page = Math.min(page, last);
|
||||
|
||||
// skip the page, if it's in the array
|
||||
if (skip.includes(page) && !prev) page = Math.max.apply(null, skip) + 1;
|
||||
if (skip.includes(page) && prev) page = Math.min.apply(null, skip) - 1;
|
||||
|
||||
// hide all forms
|
||||
// excluding id=f<page>
|
||||
for (var i = 0; i < forms.length; i++) {
|
||||
if (forms[i].id == 'f' + page) {
|
||||
forms[i].style.display = 'block';
|
||||
continue;
|
||||
}
|
||||
forms[i].style.display = 'none';
|
||||
}
|
||||
|
||||
// change buttons state
|
||||
// (active / inactive)
|
||||
if (page == 0) { btns[0].classList.add('inactive'); }
|
||||
if (page != 0) { btns[0].classList.remove('inactive'); }
|
||||
if (page == last) { btns[1].classList.add('inactive'); }
|
||||
if (page != last) { btns[1].classList.remove('inactive'); }
|
||||
|
||||
// progressbar
|
||||
document.body.style.setProperty('--pbwidth', `${100 / forms.length * page}%`);
|
||||
};
|
||||
|
||||
function regNext() {
|
||||
page++;
|
||||
changePage();
|
||||
};
|
||||
|
||||
function regPrev() {
|
||||
page--;
|
||||
prev = true;
|
||||
changePage();
|
||||
prev = false;
|
||||
};
|
||||
|
||||
function setSkip() {
|
||||
// "remove" extra pages
|
||||
// короче, оставить формы регистрации
|
||||
// только для указанного кол-ва детей,
|
||||
// остальное - лишнее (не во всех семьях 4 ребёнка)
|
||||
var el = document.querySelector('input#children');
|
||||
var count = Number(el.value) || 1;
|
||||
skip = [3, 4, 5, 6].slice(count);
|
||||
};
|
||||
|
||||
function setRequired(cbid, state) {
|
||||
// if mother/father is going to camp,
|
||||
// it is required to fill out all fields
|
||||
|
||||
var checkbox = document.getElementById(cbid);
|
||||
state = state || checkbox.checked;
|
||||
state = Boolean(state);
|
||||
checkbox.checked = state;
|
||||
|
||||
var form = checkbox.parentNode.parentNode.parentNode;
|
||||
var fields = form.querySelectorAll('input:not([type=hidden]),select');
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
fields[i].required = state;
|
||||
}
|
||||
};
|
||||
|
||||
function childInput(elem) {
|
||||
// if there's no data in an input
|
||||
if (elem.value &&
|
||||
elem.value.trim() == '') return;
|
||||
// otherwise, at least one child's
|
||||
// personal data was specified
|
||||
atleast_onechild = true;
|
||||
}
|
||||
|
||||
function computeCost() {
|
||||
|
||||
// how many people
|
||||
let count_elem = document.querySelector('input#count');
|
||||
let count = Number(count_elem.value) || 1;
|
||||
let children_elem = document.querySelector('input#children');
|
||||
let children = Number(children_elem.value) || 1;
|
||||
|
||||
// what type of house and its price
|
||||
let house = document.querySelectorAll('ul#house>li>input[type=radio]');
|
||||
let selected = 0;
|
||||
for (let h of house) {
|
||||
if (h.checked) {
|
||||
selected = h.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// camp activities and meals price
|
||||
let block = document.querySelector('div.price');
|
||||
let meal = block.querySelector('#meal').innerHTML;
|
||||
let child = block.querySelector('#child').innerHTML;
|
||||
let parent = block.querySelector('#parent').innerHTML;
|
||||
|
||||
//
|
||||
// just multiply and write the result
|
||||
|
||||
let adults = count - children;
|
||||
let house_cost = selected * count;
|
||||
let meals = meal * count;
|
||||
let camp_children = child * children;
|
||||
let camp_parents = adults * parent;
|
||||
|
||||
let field = document.querySelector('span.cost>span.number');
|
||||
field.innerHTML =
|
||||
house_cost +
|
||||
meals +
|
||||
camp_children +
|
||||
camp_parents;
|
||||
};
|
||||
|
||||
// ***
|
||||
// Sending forms data
|
||||
|
||||
function error(data, toPage) {
|
||||
|
||||
end();
|
||||
|
||||
toPage = Number(toPage);
|
||||
if (toPage) {
|
||||
page = toPage;
|
||||
changePage();
|
||||
}
|
||||
|
||||
// if it's a list of wtforms errors
|
||||
if (data && data.constructor == Array) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Неверно заполнены:',
|
||||
text: data.join(', ').toLowerCase()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise,
|
||||
// show an error message
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: data
|
||||
});
|
||||
};
|
||||
|
||||
function end() {
|
||||
inprogress = false;
|
||||
sendbtn.innerHTML = 'Отправить';
|
||||
document.cookie = 'lesa_session=del;max-age=0';
|
||||
};
|
||||
|
||||
function regSend() {
|
||||
|
||||
// if a user has already sent the data
|
||||
if (inprogress) return;
|
||||
|
||||
let main = document.querySelector('form#f0');
|
||||
sendbtn = document.querySelector('a#send');
|
||||
|
||||
// send main form
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/form/register');
|
||||
xhr.responseType = 'json';
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState != xhr.DONE)
|
||||
return;
|
||||
if (!xhr.response.ok) {
|
||||
error(xhr.response.data);
|
||||
return;
|
||||
}
|
||||
document.cookie = 'lesa_session=' + xhr.response.data + ';max-age=240';
|
||||
regParents();
|
||||
};
|
||||
xhr.send(new FormData(main));
|
||||
|
||||
// change text
|
||||
sendbtn.innerHTML = 'Отправка...';
|
||||
inprogress = true;
|
||||
};
|
||||
|
||||
function regParents() {
|
||||
|
||||
let parents = document.querySelectorAll('form#f1,form#f2');
|
||||
let last = parents.length - 1;
|
||||
let cb = [
|
||||
parents[0].querySelector('input[type=checkbox]').checked,
|
||||
parents[1].querySelector('input[type=checkbox]').checked
|
||||
];
|
||||
|
||||
if (!cb[0] && !cb[1])
|
||||
error('Не указано данных ни одного из родителей!', 1);
|
||||
|
||||
// send parents forms
|
||||
for (let i = 0; i < parents.length; i++) {
|
||||
|
||||
// check if the parent is going to camp
|
||||
if (!parents[i].querySelector('input[type=checkbox]').checked) {
|
||||
last--;
|
||||
continue;
|
||||
}
|
||||
|
||||
((i) => {
|
||||
// xhr
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/form/parents');
|
||||
xhr.responseType = 'json';
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState != xhr.DONE)
|
||||
return;
|
||||
if (!xhr.response.ok) {
|
||||
error(xhr.response.data, i + 1);
|
||||
return;
|
||||
}
|
||||
// if it's the last iteration
|
||||
if (i == last) regChildren();
|
||||
};
|
||||
xhr.send(new FormData(parents[i]));
|
||||
})(i);
|
||||
}
|
||||
};
|
||||
|
||||
function regChildren() {
|
||||
|
||||
let children = document.querySelectorAll('form#f3,form#f4,form#f5,form#f6');
|
||||
let last = children.length - 1;
|
||||
|
||||
// check if at least one child's
|
||||
// personal data was specified
|
||||
if (!atleast_onechild)
|
||||
error('Не указано данных ни одного ребёнка!', 3);
|
||||
|
||||
// send children forms
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
|
||||
// check if we need to skip the form
|
||||
id = children[i].id.replace('f', '');
|
||||
num = Number(id);
|
||||
if (skip.includes(num)) {
|
||||
last--;
|
||||
continue;
|
||||
}
|
||||
|
||||
((i) => {
|
||||
// xhr
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/form/children');
|
||||
xhr.responseType = 'json';
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState != xhr.DONE)
|
||||
return;
|
||||
if (!xhr.response.ok) {
|
||||
error(xhr.response.data);
|
||||
return;
|
||||
}
|
||||
// if it's the last iteration
|
||||
if (i == last) regComplete();
|
||||
};
|
||||
xhr.send(new FormData(children[i]));
|
||||
})(i);
|
||||
}
|
||||
};
|
||||
|
||||
function regComplete() {
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/form/complete');
|
||||
xhr.responseType = 'json';
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState != xhr.DONE)
|
||||
return;
|
||||
if (!xhr.response.ok) {
|
||||
error(xhr.response.data);
|
||||
return;
|
||||
}
|
||||
end();
|
||||
page = 0;
|
||||
changePage();
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Готово!',
|
||||
timer: 2000
|
||||
});
|
||||
window.location.hash = '';
|
||||
};
|
||||
xhr.send();
|
||||
};
|
24
lesa/static/js/script.js
Normal file
24
lesa/static/js/script.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
// Main script
|
||||
|
||||
addEventListener('load', checkTarget);
|
||||
addEventListener('hashchange', checkTarget);
|
||||
|
||||
function checkTarget() {
|
||||
addr = window.location.hash.replace(/^#/, '');
|
||||
addr = (addr != '' ? addr : 'main');
|
||||
getPage(addr);
|
||||
}
|
||||
|
||||
function toggleMap() {
|
||||
let map = document.querySelector('.map');
|
||||
let frm = map.querySelector('iframe');
|
||||
let btn = document.querySelector('.hidden-title>a');
|
||||
if (map.classList.contains('hidden')) {
|
||||
frm.src = frm.dataset.src;
|
||||
map.classList.remove('hidden');
|
||||
btn.innerHTML = 'Скрыть карту';
|
||||
return;
|
||||
}
|
||||
map.classList.add('hidden');
|
||||
btn.innerHTML = 'Показать карту';
|
||||
}
|
35
lesa/static/js/toasts.js
Normal file
35
lesa/static/js/toasts.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
// Shows Messages from Flask
|
||||
// As a SweetAlert Toasts
|
||||
|
||||
var t_elem = document.querySelector('.toasts');
|
||||
var msgs = t_elem.querySelectorAll('.msg');
|
||||
var last = msgs.length - 1;
|
||||
var shown = false;
|
||||
message(0);
|
||||
|
||||
function message(i) {
|
||||
if (shown) return;
|
||||
let el = msgs[i];
|
||||
if (!el) return;
|
||||
// show toast
|
||||
Swal.fire({
|
||||
icon: el.dataset.alert,
|
||||
title: el.innerHTML.trim(),
|
||||
position: 'top-end',
|
||||
toast: true,
|
||||
timer: 1000,
|
||||
timerProgressBar: true,
|
||||
showConfirmButton: false
|
||||
}).then(() => {
|
||||
// if it's the last notification,
|
||||
// remove the element and
|
||||
// stop the function
|
||||
if (i == last) {
|
||||
t_elem.remove();
|
||||
shown = true;
|
||||
}
|
||||
// otherwise, recursively start
|
||||
// this function for the next message
|
||||
message(i + 1);
|
||||
});
|
||||
}
|
11
lesa/templates/about.html
Normal file
11
lesa/templates/about.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
<!-- About page -->
|
||||
<div class="about">
|
||||
<div class="markup">
|
||||
{% autoescape true %}
|
||||
photos={{ photos_dir }}$!
|
||||
thumbs={{ thumbs_dir }}$!
|
||||
{{ about.text }}
|
||||
{% endautoescape %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- End of template -->
|
436
lesa/templates/admin.html
Normal file
436
lesa/templates/admin.html
Normal file
|
@ -0,0 +1,436 @@
|
|||
<!DOCTYPE html>
|
||||
<html onmousedown="hideContextMenu();">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Панель администратора</title>
|
||||
<!-- Font Awesome, Swal -->
|
||||
<script src="https://kit.fontawesome.com/a966b160a8.js" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<!-- Stylesheets -->
|
||||
<link rel="stylesheet" href="/static/css/colors.css" />
|
||||
<link rel="stylesheet" href="/static/css/photos.css" />
|
||||
<link rel="stylesheet" href="/static/css/admin.css" />
|
||||
<link rel="stylesheet" href="/static/css/adminform.css" />
|
||||
<link rel="stylesheet" href="/static/css/context.css" />
|
||||
<!-- Font -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="{{font}}" />
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
// filled in admin.js
|
||||
var been_last_year = [];
|
||||
</script>
|
||||
<div class="price hidden">
|
||||
<div id="meal">{{ config.meal }}</div>
|
||||
<div id="child">{{ config.child }}</div>
|
||||
<div id="parent">{{ config.parent }}</div>
|
||||
<div id="shift">{{ shift | int }}</div>
|
||||
</div>
|
||||
<div class="toasts hidden">
|
||||
{% with msgs = get_flashed_messages() %}
|
||||
{% if msgs %}
|
||||
{% for m in msgs %}
|
||||
{% with mtype = (m|string).startswith %}
|
||||
{% with prefix = (m|string).removeprefix %}
|
||||
{% if mtype('F:') %}
|
||||
<div class="msg" data-alert="error">
|
||||
Неверно заполнены: {{ prefix('F:') }}
|
||||
</div>
|
||||
{% elif mtype('E:') %}
|
||||
<div class="msg" data-alert="error">
|
||||
{{ prefix('E:') }}
|
||||
</div>
|
||||
{% elif mtype('W:') %}
|
||||
<div class="msg" data-alert="warning">
|
||||
{{ prefix('W:') }}
|
||||
</div>
|
||||
{% elif mtype('I:') %}
|
||||
<div class="msg" data-alert="success">
|
||||
{{ prefix('I:') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="menu-wrapper">
|
||||
<ul class="tabs-title">
|
||||
<li>
|
||||
<a href="#posts">
|
||||
<div class="tab-name">Посты</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#about">
|
||||
<div class="tab-name">О нас</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#reg">
|
||||
<div class="tab-name">Регистрация</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#season">
|
||||
<div class="tab-name">Новый сезон</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#archive">
|
||||
<div class="tab-name">Архивация</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#account">
|
||||
<div class="tab-name">Аккаунт</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="toggle">
|
||||
<a href="javascript:toggleDark(true);">
|
||||
Тема
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="tabs-title">
|
||||
<li>
|
||||
<a href="/admin/logout">
|
||||
Выход
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tabs-wrapper">
|
||||
<div class="tab separated" id="posts">
|
||||
<form action="/admin/post" method="post" enctype="multipart/form-data">
|
||||
<div class="form-part">
|
||||
<div class="fields">
|
||||
{{ pform.hidden_tag() }}
|
||||
{{ pform.title.label }}
|
||||
{{ pform.title() }}
|
||||
{{ pform.photos.label }}
|
||||
{{ pform.photos() }}
|
||||
{{ pform.body.label }}
|
||||
{% autoescape false %}
|
||||
{{ editor('post-body') }}
|
||||
{% endautoescape %}
|
||||
{{ pform.body(cols=36, rows=10) }}
|
||||
</div>
|
||||
<input type="submit" value="Создать">
|
||||
</div>
|
||||
<div class="form-part">
|
||||
<div class="fields">
|
||||
{% for p in posts %}
|
||||
<div class="post">
|
||||
<div class="postid">
|
||||
№{{ p.id }}
|
||||
<a href="javascript:removePost({{ p.id }});" class="button">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="title">{{ p.title }}</div>
|
||||
<div class="timestamp">{{ p.dt }}</div>
|
||||
<div class="body">{{ p.body }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tab" id="about">
|
||||
<form action="/admin/about" method="post" enctype="multipart/form-data">
|
||||
{{ about.hidden_tag() }}
|
||||
<div class="photos" data-dir="{{ photos_dir }}">
|
||||
{% for p in photos %}
|
||||
<div class="photo context">
|
||||
<a href="javascript:void(0);"
|
||||
onclick="showPhoto(this);"
|
||||
oncontextmenu="photoContextMenu(event);"
|
||||
data-photo="{{ p }}">
|
||||
<img src="{{ thumbs_dir }}/{{ p }}" />
|
||||
</a>
|
||||
<ul class="context-menu" onmousedown="event.stopPropagation();">
|
||||
<li class="centered">
|
||||
{{ p }}
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:deletePhoto('{{ p }}');">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
Удалить
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:insertPhoto('{{ p }}');">
|
||||
<i class="fa-solid fa-file-circle-plus"></i>
|
||||
Вставить в текст
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{{ about.photos.label }}
|
||||
{{ about.photos() }}
|
||||
{{ about.text.label }}
|
||||
{% autoescape false %}
|
||||
{{ editor('about-text') }}
|
||||
{% endautoescape %}
|
||||
{{ about.text(cols=64, rows=16) }}
|
||||
<input type="submit" value="OK">
|
||||
</form>
|
||||
</div>
|
||||
<div class="tab" id="reg">
|
||||
<form>
|
||||
<div class="regdb-wrapper">
|
||||
<div class="actions-wrapper">
|
||||
<div class="actions">
|
||||
<div class="top-actions">
|
||||
{% if config.reg_allow %}
|
||||
<a href="javascript:closeReg();" class="button">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="javascript:openReg();" class="button">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="javascript:window.location.reload();" class="button">
|
||||
<i class="fa-solid fa-rotate-right"></i>
|
||||
</a>
|
||||
<a href="javascript:tableComputeCost();" class="button">
|
||||
<i class="fa-solid fa-ruble-sign"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="bottom-actions">
|
||||
<a href="javascript:clearReg();" class="button">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr class="caption">
|
||||
{% for c in columns %}
|
||||
<th>
|
||||
<a class="link small" href="javascript:void(0);" onclick="columnContextMenu(event);">
|
||||
{{ c.title }}
|
||||
<i class="fa-solid fa-angle-down"></i>
|
||||
</a>
|
||||
<ul class="context-menu" onmousedown="event.stopPropagation();">
|
||||
<li class="centered offset">
|
||||
<select name="sort-type">
|
||||
<option value="desc" selected="selected">
|
||||
От большего к меньшему
|
||||
</option>
|
||||
<option value="asc">
|
||||
От меньшего к большему
|
||||
</option>
|
||||
</select>
|
||||
<input type="text" name="search" placeholder="Поисковый запрос">
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onclick="filter('{{ c.id - 1 }}', this);">
|
||||
OK
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for item in rform %}
|
||||
{% with r = item.__dict__ %}
|
||||
<tr id="{{ r.mid }}" data-code="{{ r.code }}">
|
||||
{% for c in columns %}
|
||||
{% with count = (r.count or 0) | int %}
|
||||
{% with children = (r.children or 0) | int %}
|
||||
{% if c.key == 'actions' %}
|
||||
<td
|
||||
class="{{ 'done' if r.payment else '' }}"
|
||||
data-value="{{ r.payment | int }}" data-type="num">
|
||||
{% for a in config.actions %}
|
||||
{% if a == 'm' %}
|
||||
<a class="link" href="mailto:{{ r.email }}">
|
||||
<i class="fa-solid fa-at"></i>
|
||||
</a>
|
||||
{% elif a == 'p' %}
|
||||
<a class="link" href="tel:{{ r.phone }}">
|
||||
<i class="fa-solid fa-phone"></i>
|
||||
</a>
|
||||
{% elif a == 'n' %}
|
||||
<a class="link" href="tel:{{ r.phone }}">
|
||||
{{ format_phone(r.phone) }}
|
||||
</a>
|
||||
{% elif a == 'v' %}
|
||||
<a class="link" href="viber://chat/?number={{ r.phone | replace('+','%2B') }}">
|
||||
<i class="fa-brands fa-viber"></i>
|
||||
</a>
|
||||
{% elif a == 'c' %}
|
||||
<a class="link payment" href="javascript:void(0);" onclick="markAsDone(this);">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% elif c.key == 'adults' %}
|
||||
<td data-value="{{ count - children }}" data-type="num">
|
||||
{{ count - children }}
|
||||
<a class="link" href="javascript:void(0);" onclick="showParentsData(this);">
|
||||
<i class="fa-solid fa-list-ul"></i>
|
||||
</a>
|
||||
</td>
|
||||
{% elif c.key == 'children' %}
|
||||
<td class="children" data-value="{{ children }}" data-type="num">
|
||||
{{ children }}
|
||||
<a class="link" href="javascript:void(0);" onclick="showChildrenData(this);">
|
||||
<i class="fa-solid fa-list-ul"></i>
|
||||
</a>
|
||||
</td>
|
||||
{% elif c.key == 'dates' or c.key == 'house' %}
|
||||
<td class="{{ c.key }}" data-value="{{ r[c.key] }}" data-type="num">
|
||||
{% with value = r[c.key] or -1 %}
|
||||
{% with table = housedb if c.key == 'house' else dates %}
|
||||
{% with attr = 'price' if c.key == 'house' else 'id' %}
|
||||
{% with lst = table | selectattr(attr,'==',value) %}
|
||||
{{ (lst | first).title }}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
{% elif c.key == 'friends' %}
|
||||
<td data-value="{{ r.friends }}" data-type="str">
|
||||
{% if r.friends %}
|
||||
{{ r.friends }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% elif c.key == 'cost' %}
|
||||
<td class="cost-cell" data-value="0" data-type="num">
|
||||
<span class="cost"></span>
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="{{ c.key }}" data-value="{{ r[c.key] }}" data-type="{{ c.ctype }}">{{ r[c.key] }}</td>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tab separated" id="season">
|
||||
<form action="/admin/season" method="post" enctype="multipart/form-data">
|
||||
{{ sform.hidden_tag() }}
|
||||
<div class="form-part">
|
||||
<div class="fields">
|
||||
{{ sform.year.label }}
|
||||
{{ sform.year() }}
|
||||
{% for sh in sform.shifts %}
|
||||
<label class="fields-title">
|
||||
Смена {{ loop.index }}:
|
||||
</label>
|
||||
{{ sh }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input type="button" onclick="createItem('#shifts-0');" value="+">
|
||||
</div>
|
||||
<div class="form-part">
|
||||
<div class="fields">
|
||||
{% for h in sform.houses %}
|
||||
<label class="fields-title">
|
||||
Домик {{ loop.index }}:
|
||||
</label>
|
||||
{{ h }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input type="button" onclick="createItem('#houses-0');" value="+">
|
||||
</div>
|
||||
<div class="form-part">
|
||||
<div class="fields">
|
||||
{{ sform.meal.label }}
|
||||
{{ sform.meal() }}
|
||||
{{ sform.child.label }}
|
||||
{{ sform.child() }}
|
||||
{{ sform.parent.label }}
|
||||
{{ sform.parent() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-part">
|
||||
<div class="fields">
|
||||
{{ sform.name.label }}
|
||||
{{ sform.name() }}
|
||||
{{ sform.photos.label }}
|
||||
{{ sform.photos() }}
|
||||
{{ sform.embed.label }}
|
||||
{{ sform.embed() }}
|
||||
</div>
|
||||
<input type="submit" value="OK">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tab" id="archive">
|
||||
<form>
|
||||
<table class="archive-wrapper">
|
||||
<tr class="archive-option">
|
||||
<th><div class="title">Регистрационная БД</div></th>
|
||||
<td><a href="/admin/backup/regdb/xls" class="button">Excel</a></td>
|
||||
</tr>
|
||||
<tr class="archive-option">
|
||||
<th><div class="title">Регистр. БД (основные данные)</div></th>
|
||||
<td><a href="/admin/backup/reg/csv" class="button">CSV</a></td>
|
||||
<td><a href="/admin/backup/reg/xls" class="button">Excel</a></td>
|
||||
</tr>
|
||||
<tr class="archive-option">
|
||||
<th><div class="title">Регистр. БД (родители)</div></th>
|
||||
<td><a href="/admin/backup/regp/csv" class="button">CSV</a></td>
|
||||
<td><a href="/admin/backup/regp/xls" class="button">Excel</a></td>
|
||||
</tr>
|
||||
<tr class="archive-option">
|
||||
<th><div class="title">Регистр. БД (дети)</div></th>
|
||||
<td><a href="/admin/backup/regc/csv" class="button">CSV</a></td>
|
||||
<td><a href="/admin/backup/regc/xls" class="button">Excel</a></td>
|
||||
</tr>
|
||||
<tr class="archive-option">
|
||||
<th><div class="title">Все посты (текст)</div></th>
|
||||
<td><a href="/admin/backup/posts/csv" class="button">CSV</a></td>
|
||||
<td><a href="/admin/backup/posts/xls" class="button">Excel</a></td>
|
||||
</tr>
|
||||
<tr class="archive-option">
|
||||
<th><div class="title">Все изображения</div></th>
|
||||
<td><a href="/admin/backup/images/zip" class="button">ZIP</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tab" id="account">
|
||||
<form action="/admin/pswd" method="post">
|
||||
{{ aform.hidden_tag() }}
|
||||
{{ aform.oldpswd.label }}
|
||||
{{ aform.oldpswd() }}
|
||||
{{ aform.newpswd.label }}
|
||||
{{ aform.newpswd() }}
|
||||
{{ aform.confirm.label }}
|
||||
{{ aform.confirm() }}
|
||||
<input type="submit" value="Сменить">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/dark.js"></script>
|
||||
<script src="/static/js/admin.js"></script>
|
||||
<script src="/static/js/filter.js"></script>
|
||||
<script src="/static/js/photos.js"></script>
|
||||
<script src="/static/js/toasts.js"></script>
|
||||
<script src="/static/js/editor.js"></script>
|
||||
<script src="/static/js/cost.js"></script>
|
||||
</body>
|
||||
</html>
|
19
lesa/templates/children.html
Normal file
19
lesa/templates/children.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
<div class="wrapper">
|
||||
<ul class="family-list">
|
||||
{% for c in data %}
|
||||
<li>
|
||||
<div class="name {{ 'female' if c.gender else 'male' }}">
|
||||
<i class="fa-solid fa-user"></i>
|
||||
{{ c.surname }}
|
||||
{{ c.firstname }}
|
||||
[{{ 'Ж' if c.gender else 'М' }}]
|
||||
</div>
|
||||
<div class="birthday">
|
||||
<i class="fa-solid fa-calendar-days"></i>
|
||||
{{ c.birthday.strftime('%d.%m.%Y') }}
|
||||
[{{ (shift - c.birthday).days // 365 }}]
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
4
lesa/templates/closed.html
Normal file
4
lesa/templates/closed.html
Normal file
|
@ -0,0 +1,4 @@
|
|||
<div class="forms closed">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
<span class="text">Регистрация пока закрыта</span>
|
||||
</div>
|
47
lesa/templates/editor.html
Normal file
47
lesa/templates/editor.html
Normal file
|
@ -0,0 +1,47 @@
|
|||
<ul class="toolbar" data-textarea="{{ textarea_id }}">
|
||||
<li>
|
||||
<a href="javascript:void(0);" onclick="edit.bold(this);">
|
||||
<b>Ж</b>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onclick="edit.italic(this);">
|
||||
<i>К</i>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onclick="edit.underlined(this);">
|
||||
<u>Ч</u>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onclick="edit.strikeouted(this);">
|
||||
<s>З</s>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onclick="edit.heading(this);">
|
||||
<i class="fa-solid fa-heading"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onclick="edit.link(this);">
|
||||
<i class="fa-solid fa-link"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onclick="edit.list(this);">
|
||||
<i class="fa-solid fa-list-check"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onclick="edit.picture(this);">
|
||||
<i class="fa-solid fa-photo-film"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onclick="edit.linebreak(this);">
|
||||
<i class="fa-solid fa-arrows-left-right-to-line"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
47
lesa/templates/index.html
Normal file
47
lesa/templates/index.html
Normal file
|
@ -0,0 +1,47 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Семейный лагерь "Леса-Чудеса"</title>
|
||||
<!-- Font Awesome, Swal -->
|
||||
<script src="https://kit.fontawesome.com/a966b160a8.js" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<!-- Stylesheets -->
|
||||
<link rel="stylesheet" href="/static/css/colors.css" />
|
||||
<link rel="stylesheet" href="/static/css/style.css" />
|
||||
<link rel="stylesheet" href="/static/css/menu.css" />
|
||||
<link rel="stylesheet" href="/static/css/forms.css" />
|
||||
<link rel="stylesheet" href="/static/css/posts.css" />
|
||||
<link rel="stylesheet" href="/static/css/photos.css" />
|
||||
<link rel="stylesheet" href="/static/css/radio.css" />
|
||||
<!-- Font -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="{{font}}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="menu">
|
||||
<ul class="top-menu">
|
||||
<li class="logo"></li>
|
||||
<li><a href="#">Главная</a></li>
|
||||
<li><a href="#register">Регистрация</a></li>
|
||||
<li><a href="#about">О нас</a></li>
|
||||
<li><a href="https://vk.com/lesa_chudesa" target="blank"><i class="fa-brands fa-vk"></i></i></a></li>
|
||||
<li><a href="mailto:LesaChudesa@yandex.ru"><i class="fa-solid fa-at"></i></a></li>
|
||||
<li><a href="tel:+79277518996"><i class="fa-solid fa-phone"></i></a></li>
|
||||
<li><a href="javascript:toggleDark(true);"><i class="fa-solid fa-moon"></i></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="main-content">
|
||||
<div class="content-wrapper"></div>
|
||||
</div>
|
||||
<script src="/static/js/dark.js"></script>
|
||||
<script src="/static/js/markup.js"></script>
|
||||
<script src="/static/js/loader.js"></script>
|
||||
<script src="/static/js/script.js"></script>
|
||||
<script src="/static/js/photos.js"></script>
|
||||
<script src="/static/js/register.js"></script>
|
||||
<script src="/static/js/cost.js"></script>
|
||||
</body>
|
||||
</html>
|
3
lesa/templates/last_year.html
Normal file
3
lesa/templates/last_year.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
{% for i in lst %}
|
||||
{{ i.code }}
|
||||
{% endfor %}
|
37
lesa/templates/login.html
Normal file
37
lesa/templates/login.html
Normal file
|
@ -0,0 +1,37 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Вход в панель администрирования</title>
|
||||
<!-- Swal -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<!-- Stylesheets -->
|
||||
<link rel="stylesheet" href="/static/css/colors.css" />
|
||||
<link rel="stylesheet" href="/static/css/login.css" />
|
||||
<link rel="stylesheet" href="/static/css/adminform.css" />
|
||||
<!-- Font -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="{{font}}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="toasts hidden">
|
||||
{% with msgs = get_flashed_messages() %}
|
||||
{% if msgs %}
|
||||
{% for m in msgs %}
|
||||
<div class="msg" data-alert="error">{{ m }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<form action="/admin/login" method="post">
|
||||
{{ form.hidden_tag() }}
|
||||
{{ form.password.label }}
|
||||
{{ form.password(autofocus=True) }}
|
||||
<input type="submit" value="Вход">
|
||||
</form>
|
||||
<script src="/static/js/dark.js"></script>
|
||||
<script src="/static/js/toasts.js"></script>
|
||||
</body>
|
||||
</html>
|
109
lesa/templates/main.html
Normal file
109
lesa/templates/main.html
Normal file
|
@ -0,0 +1,109 @@
|
|||
<!-- Main page -->
|
||||
<div class="post-wrapper">
|
||||
<div class="post-main season">
|
||||
<h2>Лето {{ year }}</h2>
|
||||
<div class="post-text">
|
||||
<div class="post-row">
|
||||
<div class="cell">
|
||||
<h4>Смены: </h4>
|
||||
<ul>
|
||||
{% for s in shiftdb %}
|
||||
<li>{{ s.title }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h4>Домики: </h4>
|
||||
<ul>
|
||||
{% for h in housedb %}
|
||||
<li>{{ h.title }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h4>Фото: </h4>
|
||||
{% with photos_dir, thumbs_dir = html_dirs(0) %}
|
||||
{% with photos = listdir(post_dirs(0)[0])[:4] %}
|
||||
<div class="photos" data-dir="{{ photos_dir }}">
|
||||
{% for p in photos %}
|
||||
{% with last = (loop.index == 4) %}
|
||||
<div class="photo {{ 'last' if last else '' }}">
|
||||
{% if last %}
|
||||
<a href="#photos0">
|
||||
<div class="more-bg"></div>
|
||||
<div class="more">
|
||||
<span>Ещё</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="javascript:void(0);" onclick="showPhoto(this);" data-photo="{{ p }}">
|
||||
<img src="{{ thumbs_dir }}/{{ p }}" />
|
||||
</a>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="address">
|
||||
<h4>Адрес: </h4>
|
||||
<span>{{ place['name'] }}</span>
|
||||
</div>
|
||||
<div class="hidden-title">
|
||||
<a href="javascript:toggleMap();" class="button">
|
||||
Показать карту
|
||||
</a>
|
||||
</div>
|
||||
<div class="map hidden">
|
||||
<iframe
|
||||
data-src="https://yandex.ru/map-widget/v1/-/{{place['mapid']}}"
|
||||
frameborder="0" allowfullscreen="true"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups">
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% for post in news %}
|
||||
<div class="post-wrapper">
|
||||
<div class="timestamp">{{ post.dt.timestamp() }}</div>
|
||||
<div class="post-main">
|
||||
{% with photos_dir, thumbs_dir = html_dirs(post.id) %}
|
||||
{% with photos = listdir(post_dirs(post.id)[0])[:4] %}
|
||||
<h2>{{ post.title }}</h2>
|
||||
<div class="post-text">
|
||||
<div class="markup">
|
||||
{% autoescape true %}
|
||||
photos={{ photos_dir }}$!
|
||||
thumbs={{ thumbs_dir }}$!
|
||||
{{ post.body }}
|
||||
{% endautoescape %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="photos" data-dir="{{ photos_dir }}">
|
||||
{% for p in photos %}
|
||||
{% with last = (loop.index == 4) %}
|
||||
<div class="photo {{ 'last' if last else '' }}">
|
||||
{% if last %}
|
||||
<a href="#photos{{post.id}}">
|
||||
<div class="more-bg"></div>
|
||||
<div class="more">
|
||||
<span>Ещё</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="javascript:void(0);" onclick="showPhoto(this);" data-photo="{{ p }}">
|
||||
<img src="{{ thumbs_dir }}/{{ p }}" />
|
||||
</a>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<!-- End of template -->
|
32
lesa/templates/parents.html
Normal file
32
lesa/templates/parents.html
Normal file
|
@ -0,0 +1,32 @@
|
|||
<div class="wrapper">
|
||||
<ul class="family-list">
|
||||
{% for p in data %}
|
||||
<li>
|
||||
<div class="name">
|
||||
<i class="fa-solid fa-user"></i>
|
||||
{{ p.surname }}
|
||||
{{ p.firstname }}
|
||||
{{ p.midname }}
|
||||
</div>
|
||||
<div class="phone">
|
||||
<i class="fa-solid fa-phone"></i>
|
||||
<a href="tel:{{ p.phone }}">{{ format_phone(p.phone) }}</a>
|
||||
</div>
|
||||
<div class="viber">
|
||||
<i class="fa-brands fa-viber"></i>
|
||||
<a href="viber://chat/?number={{ p.phone | replace('+','%2B') }}">Открыть Viber</a>
|
||||
</div>
|
||||
<div class="email">
|
||||
<i class="fa-solid fa-at"></i>
|
||||
<a href="mailto:{{ p.email }}">{{ p.email }}</a>
|
||||
</div>
|
||||
{% if p.social %}
|
||||
<div class="social">
|
||||
<i class="fa-solid fa-comment"></i>
|
||||
<a href="{{ p.social }}">{{ p.social }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
11
lesa/templates/photos.html
Normal file
11
lesa/templates/photos.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
<!-- Photos page -->
|
||||
<div class="photos large" data-dir="{{ photos_dir }}">
|
||||
{% for p in photos %}
|
||||
<div class="photo">
|
||||
<a href="javascript:void(0);" onclick="showPhoto(this);" data-photo="{{ p }}">
|
||||
<img src="{{ thumbs_dir }}/{{ p }}" />
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<!-- End of template -->
|
150
lesa/templates/register.html
Normal file
150
lesa/templates/register.html
Normal file
|
@ -0,0 +1,150 @@
|
|||
<!-- Register form -->
|
||||
<div class="reg-progress"></div>
|
||||
<div class="forms-wrapper">
|
||||
<form class="forms">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form" id="f0">
|
||||
<div class="input">
|
||||
<div class="input-label">{{ form.family.label }}</div>
|
||||
<div class="input-field">{{ form.family() }}</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ form.dates.label }}</div>
|
||||
<div class="input-field">{{ form.dates() }}</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ form.count.label }}</div>
|
||||
<div class="input-field">{{ form.count(oninput="computeCost()") }}</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ form.children.label }}</div>
|
||||
<div class="input-field">
|
||||
{{ form.children(oninput="updateChildrenFields(this.value);computeCost()") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ form.meal_count.label }}</div>
|
||||
<div class="input-field">{{ form.meal_count(oninput="computeCost()") }}</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ form.house.label }}</div>
|
||||
<div class="input-field">{{ form.house(oninput="computeCost()") }}</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ form.friends.label }}</div>
|
||||
<div class="input-field">{{ form.friends() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden js-form" id="parent-form">
|
||||
{% with pform = form.parentslst[0] %}
|
||||
<div class="fields">
|
||||
{{ pform.hidden_tag() }}
|
||||
<div class="input">
|
||||
<div class="input-label">{{ pform.surname.label }}</div>
|
||||
<div class="input-field">{{ pform.surname() }}</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ pform.firstname.label }}</div>
|
||||
<div class="input-field">{{ pform.firstname() }}</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ pform.midname.label }}</div>
|
||||
<div class="input-field">{{ pform.midname() }}</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ pform.phone.label }}</div>
|
||||
<div class="input-field">{{ pform.phone(oninput="changeMainPhone()") }}</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ pform.email.label }}</div>
|
||||
<div class="input-field">{{ pform.email(oninput="changeMainEmail()") }}</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ pform.social.label }}</div>
|
||||
<div class="input-field">{{ pform.social() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% for i in [1,2] %}
|
||||
<div class="form parent" id="f{{i}}">
|
||||
<div class="input">
|
||||
<div class="input-field">
|
||||
<input type="checkbox" id="going-{{i}}" oninput="updateParentsFields(this);">
|
||||
<label for="going-{{i}}">
|
||||
<b>
|
||||
{{ 'Мама' if i == 1 else 'Папа' }}
|
||||
едет в лагерь?
|
||||
</b>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="hidden js-form" id="child-form">
|
||||
{% with cform = form.childrenlst[0] %}
|
||||
<div class="fields">
|
||||
{{ cform.hidden_tag() }}
|
||||
<div class="input">
|
||||
<div class="input-label">{{ cform.surname.label }}</div>
|
||||
<div class="input-field">{{ cform.surname() }}</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ cform.firstname.label }}</div>
|
||||
<div class="input-field">{{ cform.firstname() }}</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ cform.gender.label }}</div>
|
||||
<div class="input-field">{{ cform.gender() }}</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ cform.birthday.label }}</div>
|
||||
<div class="input-field">{{ cform.birthday(oninput="computeCost()") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="form complete">
|
||||
<div class="fields">
|
||||
<div class="input">
|
||||
<div class="input-field">
|
||||
<input type="checkbox" id="been-last-year" oninput="computeCost(this);">
|
||||
<label for="been-last-year">
|
||||
Вы были в лагере в прошлом году?
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ form.main_email.label }}</div>
|
||||
<div class="input-field">
|
||||
{{ form.main_email(class='hide-items') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<div class="input-label">{{ form.main_phone.label }}</div>
|
||||
<div class="input-field">
|
||||
{{ form.main_phone(class='hide-items') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Проверьте введённые данные, пользуясь кнопками [Назад] и [Далее].<br />
|
||||
Внизу указана приблизительная стоимость.<br />
|
||||
Для завершения регистрации нажмите:
|
||||
<a href="javascript:regSend();" class="button" id="send">Отправить</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="actions">
|
||||
<div class="hidden price">
|
||||
<div id="meal">{{ price['meal'] }}</div>
|
||||
<div id="child">{{ price['child'] }}</div>
|
||||
<div id="parent">{{ price['parent'] }}</div>
|
||||
<div id="shift">{{ shift | int }}</div>
|
||||
</div>
|
||||
<footer>
|
||||
<a href="javascript:regPrev();" class="button">Назад</a>
|
||||
<a href="javascript:regNext();" class="button">Далее</a>
|
||||
<span class="cost"><span class="number">0</span> руб./сут.</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End of template -->
|
155
lesa/upload.py
Normal file
155
lesa/upload.py
Normal file
|
@ -0,0 +1,155 @@
|
|||
import re
|
||||
import os
|
||||
import shutil
|
||||
import secrets
|
||||
from PIL import Image
|
||||
from flask_wtf.file import FileStorage
|
||||
from typing import Any, Union, List, Tuple, Callable
|
||||
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
upload_dir = os.path.join(basedir, 'static', 'upload')
|
||||
|
||||
# .../static/upload/photos,thumbs
|
||||
photos_dir, thumbs_dir = tuple(map(
|
||||
lambda dir: os.path.join(upload_dir, dir),
|
||||
['photos', 'thumbs']
|
||||
))
|
||||
|
||||
updirs = (photos_dir, thumbs_dir)
|
||||
|
||||
def post_dirs(post:Union[int,str]) -> Tuple[str]:
|
||||
|
||||
id = str(post)
|
||||
# .../static/upload/photos,thumbs/id
|
||||
return tuple(map(
|
||||
lambda dir: os.path.join(dir, id),
|
||||
(photos_dir, thumbs_dir)
|
||||
))
|
||||
|
||||
def html_dirs(post:Union[int,str]) -> Tuple[str]:
|
||||
|
||||
id = str(post)
|
||||
# /static/upload/photos,thumbs/id
|
||||
# only for html!
|
||||
return tuple(map(
|
||||
lambda dir:
|
||||
os.path.join(dir, id)\
|
||||
.replace(basedir, ''),
|
||||
(photos_dir, thumbs_dir)
|
||||
))
|
||||
|
||||
def listdir(path:str) -> List[str]:
|
||||
try:
|
||||
return os.listdir(path)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def save_imgs(
|
||||
pics:Tuple[FileStorage],
|
||||
post:Union[int,str]=0,
|
||||
remove:bool=True) -> None:
|
||||
|
||||
id = str(post)
|
||||
|
||||
if remove:
|
||||
remove_dirs(post)
|
||||
check_dirs(post)
|
||||
|
||||
for pic in pics:
|
||||
|
||||
# secure name
|
||||
fname = filename(pic.filename, post)
|
||||
|
||||
# generates something like
|
||||
# .../static/upload/photos/123/myphoto.jpg
|
||||
# .../static/upload/thumbs/123/myphoto.jpg
|
||||
# calling map for photos and thumbs dir
|
||||
orig_path, thumb_path = \
|
||||
tuple(map(
|
||||
lambda dir: os.path.join(dir, id, fname),
|
||||
(photos_dir, thumbs_dir)
|
||||
))
|
||||
|
||||
# save original
|
||||
pic.save(orig_path)
|
||||
|
||||
# save thumbnail
|
||||
size = (196, 196)
|
||||
img = Image.open(pic.stream)
|
||||
img.thumbnail(size)
|
||||
img.save(thumb_path)
|
||||
|
||||
def check_dirs(
|
||||
post:Union[int,str]=-1,
|
||||
dirs:Tuple[str]=updirs) -> None:
|
||||
|
||||
process_dirs(
|
||||
lambda p,e: os.makedirs(p) if not e else None,
|
||||
post, dirs
|
||||
)
|
||||
|
||||
def remove_dirs(
|
||||
post:Union[int,str]=-1,
|
||||
dirs:Tuple[str]=updirs) -> None:
|
||||
|
||||
process_dirs(
|
||||
lambda p,e: shutil.rmtree(p) if e else None,
|
||||
post, dirs
|
||||
)
|
||||
|
||||
# callback args: dir path, does it exist
|
||||
def process_dirs(
|
||||
callback:Callable[[str,bool],Any],
|
||||
post:Union[int,str]=-1,
|
||||
dirs:Tuple[str]=updirs) -> Tuple[Any]:
|
||||
|
||||
def process(dir):
|
||||
path = os.path.join(dir, id)
|
||||
exists = os.path.exists(path)
|
||||
return callback(path, exists)
|
||||
|
||||
id = str(post) if isinstance(post, str) or post > -1 else ''
|
||||
return tuple(map(process, dirs))
|
||||
|
||||
def filename(
|
||||
name:str,
|
||||
post:Union[int,str]=-1,
|
||||
dirs:Tuple[str]=updirs) -> str:
|
||||
|
||||
# remove unsafe symbols and ..
|
||||
res = re.sub(
|
||||
r'[^A-Za-zА-Яа-я0-9\-+.]', '_',
|
||||
name
|
||||
).replace('..', '')
|
||||
|
||||
# the filename should not begin or end with .
|
||||
res = re.sub(r'(?:^\.)|(?:\.$)', '', res)
|
||||
|
||||
# if the name is empty
|
||||
if res.strip() == '':
|
||||
res = 'file.png'
|
||||
|
||||
# if the name is too long
|
||||
if len(res) > 32:
|
||||
# crop to the 30th character
|
||||
# from the end
|
||||
res = res[-30:]
|
||||
|
||||
#
|
||||
# if the file is already exists
|
||||
#
|
||||
result = process_dirs(
|
||||
lambda path, _e:
|
||||
os.path.exists(
|
||||
os.path.join(path, name)
|
||||
),
|
||||
post, dirs
|
||||
)
|
||||
print(result)
|
||||
# if at least one path exists,
|
||||
if any(result):
|
||||
# add some random symbols
|
||||
# at the beginning of the filename
|
||||
res = secrets.token_urlsafe(4) + res
|
||||
|
||||
return res
|
7
main.py
Normal file
7
main.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
# For Deta
|
||||
|
||||
import waitress
|
||||
from lesa.app import app
|
||||
|
||||
if __name__ == '__main__':
|
||||
waitress.serve(app)
|
10
requirements.txt
Normal file
10
requirements.txt
Normal file
|
@ -0,0 +1,10 @@
|
|||
waitress
|
||||
flask
|
||||
flask-wtf
|
||||
flask-sqlalchemy
|
||||
flask-migrate
|
||||
flask-bcrypt
|
||||
email-validator
|
||||
phonenumberslite
|
||||
pillow
|
||||
openpyxl
|
21
setup.py
Normal file
21
setup.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='lesa',
|
||||
version='1.0.0',
|
||||
packages=['lesa'],
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
install_requires=[
|
||||
'waitress',
|
||||
'flask',
|
||||
'flask-wtf',
|
||||
'flask-sqlalchemy',
|
||||
'flask-migrate',
|
||||
'flask-bcrypt',
|
||||
'email-validator',
|
||||
'phonenumberslite',
|
||||
'pillow',
|
||||
'openpyxl'
|
||||
],
|
||||
)
|
BIN
tests/__pycache__/test_reg_performance.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_reg_performance.cpython-310.pyc
Normal file
Binary file not shown.
1
tests/requirements.txt
Normal file
1
tests/requirements.txt
Normal file
|
@ -0,0 +1 @@
|
|||
selenium
|
136
tests/test_reg_performance.py
Normal file
136
tests/test_reg_performance.py
Normal file
|
@ -0,0 +1,136 @@
|
|||
import re
|
||||
import random
|
||||
import asyncio
|
||||
import unittest
|
||||
import requests
|
||||
import randomus
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
CSRF_MAIN = r'<input id="csrf_token" .+? value="(.+?)">'
|
||||
CSRF_PARENT = r'<input id="parentslst-0-csrf_token" .+? value="(.+?)">'
|
||||
CSRF_CHILD = r'<input id="childrenlst-0-csrf_token" .+? value="(.+?)">'
|
||||
|
||||
MAX_REQ = 100
|
||||
COUNT = 200
|
||||
|
||||
class Test_RegPerformance(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.names = []
|
||||
repeat = COUNT / MAX_REQ
|
||||
i = repeat
|
||||
while i > 0:
|
||||
n = int(COUNT / repeat)
|
||||
self.names.extend(randomus.generate_names(n))
|
||||
i -= 1
|
||||
|
||||
def test_performance(self):
|
||||
asyncio.run(self.performance())
|
||||
|
||||
async def performance(self):
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
length = len(self.names)
|
||||
for i, n in enumerate(self.names):
|
||||
|
||||
parts = n.split(' ')
|
||||
sur = parts[0]
|
||||
first = parts[1]
|
||||
mid = parts[2]
|
||||
|
||||
next_name = n
|
||||
if i < length - 1:
|
||||
next_name = self.names[i + 1]
|
||||
next_parts = next_name.split(' ')
|
||||
next_first = next_parts[1]
|
||||
|
||||
loop.create_task(
|
||||
self.register(
|
||||
sur,
|
||||
random.randint(0,1),
|
||||
random.randint(1,5),
|
||||
random.choice((410, 950)),
|
||||
|
||||
sur, first, mid,
|
||||
self.phone(), 'test@example.com', '',
|
||||
|
||||
sur, next_first,
|
||||
random.choice((0,1)),
|
||||
'1998-01-05',
|
||||
|
||||
random.choice(('Aaa','Bbb','Ccc','Ddd'))
|
||||
)
|
||||
)
|
||||
|
||||
for i in asyncio.all_tasks(loop):
|
||||
if (i.get_name() != 'Task-1'):
|
||||
await i
|
||||
res: timedelta = i.result()
|
||||
self.assertLessEqual(res.total_seconds(), 1.6)
|
||||
|
||||
@staticmethod
|
||||
def phone() -> str:
|
||||
res = ''
|
||||
for _ in range(9):
|
||||
res += str(random.randint(0,9))
|
||||
return '9' + res
|
||||
|
||||
@staticmethod
|
||||
def csrf() -> str:
|
||||
|
||||
resp = requests.get('http://localhost:5000/page/register')
|
||||
resp.raise_for_status()
|
||||
csrf_main = re.search(CSRF_MAIN, resp.text)[1]
|
||||
return csrf_main
|
||||
|
||||
async def register(
|
||||
self,
|
||||
family: str, dates: int,
|
||||
count: int, house: int,
|
||||
|
||||
parent_surname: str,
|
||||
parent_name: str,
|
||||
parent_midname: str,
|
||||
parent_phone: str,
|
||||
parent_email: str,
|
||||
parent_social: str,
|
||||
|
||||
child_surname: str,
|
||||
child_firstname: str,
|
||||
child_gender: str,
|
||||
child_bday: str,
|
||||
|
||||
friends: str = '') -> timedelta:
|
||||
|
||||
self.csrf_token = self.csrf()
|
||||
|
||||
start = datetime.now()
|
||||
requests.post(
|
||||
url='http://localhost:5000/form/register',
|
||||
data={
|
||||
'csrf_token': self.csrf_token,
|
||||
'family': family,
|
||||
'dates': dates,
|
||||
'count': count,
|
||||
'children': 1,
|
||||
'meal_count': 1,
|
||||
'house': house,
|
||||
'friends': friends,
|
||||
'parentslst-0-csrf_token': self.csrf_token,
|
||||
'parentslst-0-surname': parent_surname,
|
||||
'parentslst-0-firstname': parent_name,
|
||||
'parentslst-0-midname': parent_midname,
|
||||
'parentslst-0-phone': parent_phone,
|
||||
'parentslst-0-email': parent_email,
|
||||
'parentslst-0-social': parent_social,
|
||||
'childrenlst-0-csrf_token': self.csrf_token,
|
||||
'childrenlst-0-surname': child_surname,
|
||||
'childrenlst-0-firstname': child_firstname,
|
||||
'childrenlst-0-gender': child_gender,
|
||||
'childrenlst-0-birthday': child_bday,
|
||||
}
|
||||
)
|
||||
end = datetime.now()
|
||||
return (end - start)
|
Loading…
Add table
Reference in a new issue