First commit

This commit is contained in:
DarkCat09 2022-07-04 21:13:59 +04:00
commit e77f8e8b58
62 changed files with 5511 additions and 0 deletions

3
MANIFEST.in Normal file
View file

@ -0,0 +1,3 @@
graft lesa/static
graft lesa/templates
global-exclude *.pyc

16
TODO.md Normal file
View file

@ -0,0 +1,16 @@
## Список TODO
### Главная страница
- Возможность выбрать между Google/Яндекс/OSM картами,
по умолчанию - OSM, т.к. нет аналитики и рекламы.
### Язык разметки
- Вставка карты (G/Я/OSM) в текст.
- Вставка видео.
- Загрузка картинок на сервер при oninput на input:file.
### Панель администратора
- Нормальная сортировка: вместо функций JS запросы к БД,
с ограничением на 50-70 строк, чтобы не заполнять этим ОЗУ.
- Получение даты рождения всех детей для расчёта скидки на третьего.
- Импортирование постов из ВКонтакте во вкладке "Посты".

4
lesa/__init__.py Normal file
View file

@ -0,0 +1,4 @@
from .app import app
def create_app():
return app

22
lesa/admin/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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%);
}

View 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);
}

View 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
View 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
View 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
View 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);
}

View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

BIN
lesa/static/img/lesa.xcf Normal file

Binary file not shown.

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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();
}

View 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
View 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
View 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
View 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
View 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>

View 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>

View file

@ -0,0 +1,4 @@
<div class="forms closed">
<i class="fa-solid fa-lock"></i>
<span class="text">Регистрация пока закрыта</span>
</div>

View 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
View 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>

View file

@ -0,0 +1,3 @@
{% for i in lst %}
{{ i.code }}
{% endfor %}

37
lesa/templates/login.html Normal file
View 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
View 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 -->

View 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>

View 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 -->

View 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
View 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
View 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
View 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
View 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'
],
)

Binary file not shown.

1
tests/requirements.txt Normal file
View file

@ -0,0 +1 @@
selenium

View 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)