sqlalchemy-connection 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlalchemy_connection-2.0.1.dist-info/METADATA +26 -0
- sqlalchemy_connection-2.0.1.dist-info/RECORD +33 -0
- sqlalchemy_connection-2.0.1.dist-info/WHEEL +5 -0
- sqlalchemy_connection-2.0.1.dist-info/entry_points.txt +2 -0
- sqlalchemy_connection-2.0.1.dist-info/top_level.txt +1 -0
- sqlalchemy_connector/__init__.py +3 -0
- sqlalchemy_connector/_builder.py +425 -0
- sqlalchemy_connector/cli.py +200 -0
- sqlalchemy_connector/real_generator.py +2908 -0
- sqlalchemy_connector/templates/admin_cart_html_template.html +372 -0
- sqlalchemy_connector/templates/admin_html_template.html +364 -0
- sqlalchemy_connector/templates/admin_users_html_template.html +82 -0
- sqlalchemy_connector/templates/app_template.py +434 -0
- sqlalchemy_connector/templates/base_html_template.html +100 -0
- sqlalchemy_connector/templates/cart_html_template.html +103 -0
- sqlalchemy_connector/templates/catalog_html_template.html +98 -0
- sqlalchemy_connector/templates/checkout_html_template.html +70 -0
- sqlalchemy_connector/templates/dashboard_html_template.html +121 -0
- sqlalchemy_connector/templates/index_html_template.html +91 -0
- sqlalchemy_connector/templates/login_html_template.html +59 -0
- sqlalchemy_connector/templates/models_template.py +65 -0
- sqlalchemy_connector/templates/new_request_html_template.html +49 -0
- sqlalchemy_connector/templates/orders_html_template.html +65 -0
- sqlalchemy_connector/templates/product_form_html_template.html +142 -0
- sqlalchemy_connector/templates/product_html_template.html +131 -0
- sqlalchemy_connector/templates/profile_html_template.html +104 -0
- sqlalchemy_connector/templates/register_html_template.html +183 -0
- sqlalchemy_connector/templates/reviews_html_template.html +104 -0
- sqlalchemy_connector/templates/service_detail_html_template.html +67 -0
- sqlalchemy_connector/templates/service_form_html_template.html +86 -0
- sqlalchemy_connector/templates/services_html_template.html +47 -0
- sqlalchemy_connector/templates/slider_js_template.js +99 -0
- sqlalchemy_connector/templates/style_css_template.css +502 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
from flask import Flask, render_template, request, redirect, url_for, session, jsonify, flash
|
|
2
|
+
from flask_sqlalchemy import SQLAlchemy
|
|
3
|
+
from werkzeug.security import generate_password_hash, check_password_hash
|
|
4
|
+
from werkzeug.utils import secure_filename
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from models import db, User, Request
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
app = Flask(__name__)
|
|
12
|
+
app.secret_key = 'exam_secret_key_2024_very_long_and_secure'
|
|
13
|
+
# Путь к БД — папка instance создаётся автоматически
|
|
14
|
+
_instance_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'instance')
|
|
15
|
+
os.makedirs(_instance_path, exist_ok=True)
|
|
16
|
+
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(_instance_path, 'site.db')
|
|
17
|
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
|
18
|
+
app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'uploads')
|
|
19
|
+
app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 # 5MB
|
|
20
|
+
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
|
21
|
+
|
|
22
|
+
db.init_app(app)
|
|
23
|
+
|
|
24
|
+
ALLOWED_IMAGE_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'webp'}
|
|
25
|
+
STATUS_LIST = {{STATUS_LIST}}
|
|
26
|
+
|
|
27
|
+
def allowed_file(filename, max_size_mb=5):
|
|
28
|
+
"""Проверка допустимого расширения файла"""
|
|
29
|
+
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
|
|
30
|
+
|
|
31
|
+
def save_uploaded_file(file, subfolder=''):
|
|
32
|
+
"""Сохраняет загруженный файл и возвращает имя файла"""
|
|
33
|
+
if not file or file.filename == '':
|
|
34
|
+
return None
|
|
35
|
+
if not allowed_file(file.filename):
|
|
36
|
+
return None
|
|
37
|
+
filename = secure_filename(file.filename)
|
|
38
|
+
# Добавляем временную метку для уникальности
|
|
39
|
+
name, ext = os.path.splitext(filename)
|
|
40
|
+
filename = f"{name}_{int(datetime.utcnow().timestamp())}{ext}"
|
|
41
|
+
folder = os.path.join(app.config['UPLOAD_FOLDER'], subfolder)
|
|
42
|
+
os.makedirs(folder, exist_ok=True)
|
|
43
|
+
file.save(os.path.join(folder, filename))
|
|
44
|
+
return filename
|
|
45
|
+
|
|
46
|
+
def login_required(f):
|
|
47
|
+
"""Декоратор: требует авторизации"""
|
|
48
|
+
from functools import wraps
|
|
49
|
+
@wraps(f)
|
|
50
|
+
def decorated(*args, **kwargs):
|
|
51
|
+
if 'user_id' not in session:
|
|
52
|
+
return redirect(url_for('login'))
|
|
53
|
+
return f(*args, **kwargs)
|
|
54
|
+
return decorated
|
|
55
|
+
|
|
56
|
+
def admin_required(f):
|
|
57
|
+
"""Декоратор: требует роли admin"""
|
|
58
|
+
from functools import wraps
|
|
59
|
+
@wraps(f)
|
|
60
|
+
def decorated(*args, **kwargs):
|
|
61
|
+
if 'user_id' not in session or session.get('role') != 'admin':
|
|
62
|
+
return redirect(url_for('login'))
|
|
63
|
+
return f(*args, **kwargs)
|
|
64
|
+
return decorated
|
|
65
|
+
|
|
66
|
+
# ─────────────────────────────────────────────
|
|
67
|
+
# ГЛАВНАЯ
|
|
68
|
+
# ─────────────────────────────────────────────
|
|
69
|
+
@app.route('/')
|
|
70
|
+
def index():
|
|
71
|
+
# Статистика для счётчика
|
|
72
|
+
completed_count = Request.query.filter(Request.status == STATUS_LIST[-1]).count()
|
|
73
|
+
{{BEFORE_AFTER_INDEX_QUERY}}
|
|
74
|
+
return render_template('index.html', completed_count=completed_count{{BEFORE_AFTER_INDEX_PASS}})
|
|
75
|
+
|
|
76
|
+
@app.route('/api/stats')
|
|
77
|
+
def api_stats():
|
|
78
|
+
"""AJAX-эндпоинт для обновления счётчика"""
|
|
79
|
+
completed_count = Request.query.filter(Request.status == STATUS_LIST[-1]).count()
|
|
80
|
+
total_count = Request.query.count()
|
|
81
|
+
return jsonify({'completed': completed_count, 'total': total_count})
|
|
82
|
+
|
|
83
|
+
# ─────────────────────────────────────────────
|
|
84
|
+
# РЕГИСТРАЦИЯ
|
|
85
|
+
# ─────────────────────────────────────────────
|
|
86
|
+
@app.route('/register', methods=['GET', 'POST'])
|
|
87
|
+
def register():
|
|
88
|
+
if request.method == 'POST':
|
|
89
|
+
login_val = request.form.get('login', '').strip()
|
|
90
|
+
password = request.form.get('password', '')
|
|
91
|
+
confirm = request.form.get('confirm_password', '')
|
|
92
|
+
errors = {}
|
|
93
|
+
|
|
94
|
+
# Валидация логина
|
|
95
|
+
if not re.match(r'^[a-zA-Z0-9]{6,}$', login_val):
|
|
96
|
+
errors['login'] = 'Логин: только латиница и цифры, минимум 6 символов'
|
|
97
|
+
elif User.query.filter_by(login=login_val).first():
|
|
98
|
+
errors['login'] = 'Этот логин уже занят'
|
|
99
|
+
|
|
100
|
+
# Валидация пароля
|
|
101
|
+
if len(password) < 8:
|
|
102
|
+
errors['password'] = 'Пароль должен содержать минимум 8 символов'
|
|
103
|
+
elif not re.search(r'[A-Z]', password):
|
|
104
|
+
errors['password'] = 'Пароль должен содержать хотя бы одну заглавную букву'
|
|
105
|
+
elif not re.search(r'[a-z]', password):
|
|
106
|
+
errors['password'] = 'Пароль должен содержать хотя бы одну строчную букву'
|
|
107
|
+
elif not re.search(r'\d', password):
|
|
108
|
+
errors['password'] = 'Пароль должен содержать хотя бы одну цифру'
|
|
109
|
+
|
|
110
|
+
if password != confirm:
|
|
111
|
+
errors['confirm_password'] = 'Пароли не совпадают'
|
|
112
|
+
|
|
113
|
+
# Валидация email (если поле есть)
|
|
114
|
+
email_val = request.form.get('email', '').strip()
|
|
115
|
+
if email_val:
|
|
116
|
+
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email_val):
|
|
117
|
+
errors['email'] = 'Введите корректный email'
|
|
118
|
+
elif User.query.filter_by(email=email_val).first():
|
|
119
|
+
errors['email'] = 'Этот email уже зарегистрирован'
|
|
120
|
+
|
|
121
|
+
# Валидация ФИО (если поле есть)
|
|
122
|
+
full_name_val = request.form.get('full_name', '').strip()
|
|
123
|
+
if full_name_val:
|
|
124
|
+
if not re.match(r'^[А-ЯЁа-яёA-Za-z][А-ЯЁа-яёA-Za-z\s\-]+$', full_name_val):
|
|
125
|
+
errors['full_name'] = 'ФИО: только буквы, пробелы и дефисы'
|
|
126
|
+
elif len(full_name_val.split()) < 2:
|
|
127
|
+
errors['full_name'] = 'Введите минимум два слова (имя и фамилию)'
|
|
128
|
+
|
|
129
|
+
# Валидация телефона (если поле есть)
|
|
130
|
+
phone_val = request.form.get('phone', '').strip()
|
|
131
|
+
if phone_val:
|
|
132
|
+
# Оставляем только цифры
|
|
133
|
+
phone_digits = re.sub(r'\D', '', phone_val)
|
|
134
|
+
if len(phone_digits) < 10:
|
|
135
|
+
errors['phone'] = 'Введите корректный номер телефона'
|
|
136
|
+
else:
|
|
137
|
+
phone_val = phone_digits
|
|
138
|
+
|
|
139
|
+
# Согласие
|
|
140
|
+
if not request.form.get('rules'):
|
|
141
|
+
errors['rules'] = 'Необходимо согласие на обработку данных'
|
|
142
|
+
|
|
143
|
+
if errors:
|
|
144
|
+
return jsonify({'success': False, 'errors': errors})
|
|
145
|
+
|
|
146
|
+
# Собираем дополнительные поля
|
|
147
|
+
extra_data = {}
|
|
148
|
+
if full_name_val:
|
|
149
|
+
extra_data['full_name'] = full_name_val
|
|
150
|
+
if email_val:
|
|
151
|
+
extra_data['email'] = email_val
|
|
152
|
+
if phone_val:
|
|
153
|
+
extra_data['phone'] = phone_val
|
|
154
|
+
{{CUSTOM_USER_EXTRA_SAVE}}
|
|
155
|
+
|
|
156
|
+
user = User(
|
|
157
|
+
login=login_val,
|
|
158
|
+
password=generate_password_hash(password),
|
|
159
|
+
role='user',
|
|
160
|
+
**extra_data
|
|
161
|
+
)
|
|
162
|
+
db.session.add(user)
|
|
163
|
+
db.session.commit()
|
|
164
|
+
|
|
165
|
+
return jsonify({'success': True, 'redirect': url_for('login')})
|
|
166
|
+
|
|
167
|
+
return render_template('register.html')
|
|
168
|
+
|
|
169
|
+
@app.route('/check_login')
|
|
170
|
+
def check_login():
|
|
171
|
+
"""AJAX-проверка уникальности логина"""
|
|
172
|
+
login_val = request.args.get('login', '').strip()
|
|
173
|
+
exists = User.query.filter_by(login=login_val).first() is not None
|
|
174
|
+
return jsonify({'exists': exists})
|
|
175
|
+
|
|
176
|
+
# ─────────────────────────────────────────────
|
|
177
|
+
# АВТОРИЗАЦИЯ
|
|
178
|
+
# ─────────────────────────────────────────────
|
|
179
|
+
@app.route('/login', methods=['GET', 'POST'])
|
|
180
|
+
def login():
|
|
181
|
+
if request.method == 'POST':
|
|
182
|
+
login_val = request.form.get('login', '').strip()
|
|
183
|
+
password = request.form.get('password', '')
|
|
184
|
+
user = User.query.filter_by(login=login_val).first()
|
|
185
|
+
|
|
186
|
+
# Проверка блокировки
|
|
187
|
+
if user and user.locked_until and user.locked_until > datetime.utcnow():
|
|
188
|
+
remaining = int((user.locked_until - datetime.utcnow()).total_seconds() / 60) + 1
|
|
189
|
+
flash(f'Аккаунт заблокирован. Попробуйте через {remaining} мин.', 'danger')
|
|
190
|
+
return render_template('login.html')
|
|
191
|
+
|
|
192
|
+
if user and not user.is_blocked and check_password_hash(user.password, password):
|
|
193
|
+
# Сброс счётчика попыток
|
|
194
|
+
user.login_attempts = 0
|
|
195
|
+
user.locked_until = None
|
|
196
|
+
db.session.commit()
|
|
197
|
+
|
|
198
|
+
session['user_id'] = user.id
|
|
199
|
+
session['role'] = user.role
|
|
200
|
+
session['login'] = user.login
|
|
201
|
+
|
|
202
|
+
if user.role == 'admin':
|
|
203
|
+
return redirect(url_for('admin'))
|
|
204
|
+
return redirect(url_for('dashboard'))
|
|
205
|
+
|
|
206
|
+
# Неудачная попытка
|
|
207
|
+
if user:
|
|
208
|
+
user.login_attempts = (user.login_attempts or 0) + 1
|
|
209
|
+
if user.login_attempts >= 5:
|
|
210
|
+
user.locked_until = datetime.utcnow() + timedelta(minutes=15)
|
|
211
|
+
user.login_attempts = 0
|
|
212
|
+
db.session.commit()
|
|
213
|
+
flash('Слишком много попыток. Аккаунт заблокирован на 15 минут.', 'danger')
|
|
214
|
+
return render_template('login.html')
|
|
215
|
+
db.session.commit()
|
|
216
|
+
|
|
217
|
+
flash('Неверный логин или пароль', 'danger')
|
|
218
|
+
return render_template('login.html')
|
|
219
|
+
|
|
220
|
+
return render_template('login.html')
|
|
221
|
+
|
|
222
|
+
@app.route('/logout')
|
|
223
|
+
def logout():
|
|
224
|
+
session.clear()
|
|
225
|
+
return redirect(url_for('index'))
|
|
226
|
+
|
|
227
|
+
# ─────────────────────────────────────────────
|
|
228
|
+
# ЛИЧНЫЙ КАБИНЕТ
|
|
229
|
+
# ─────────────────────────────────────────────
|
|
230
|
+
@app.route('/dashboard')
|
|
231
|
+
@login_required
|
|
232
|
+
def dashboard():
|
|
233
|
+
user = User.query.get(session['user_id'])
|
|
234
|
+
status_filter = request.args.get('status', 'all')
|
|
235
|
+
if status_filter and status_filter != 'all':
|
|
236
|
+
user_requests = Request.query.filter_by(user_id=user.id, status=status_filter).order_by(Request.created_at.desc()).all()
|
|
237
|
+
else:
|
|
238
|
+
user_requests = Request.query.filter_by(user_id=user.id).order_by(Request.created_at.desc()).all()
|
|
239
|
+
{{DASHBOARD_ORDERS_QUERY}}
|
|
240
|
+
return render_template('dashboard.html', user=user, requests=user_requests,
|
|
241
|
+
status_list=STATUS_LIST, current_status=status_filter{{DASHBOARD_ORDERS_PASS}})
|
|
242
|
+
|
|
243
|
+
# ─────────────────────────────────────────────
|
|
244
|
+
# ПРОФИЛЬ
|
|
245
|
+
# ─────────────────────────────────────────────
|
|
246
|
+
@app.route('/profile', methods=['GET', 'POST'])
|
|
247
|
+
@login_required
|
|
248
|
+
def profile():
|
|
249
|
+
user = User.query.get(session['user_id'])
|
|
250
|
+
if request.method == 'POST':
|
|
251
|
+
action = request.form.get('action', 'update')
|
|
252
|
+
|
|
253
|
+
if action == 'change_password':
|
|
254
|
+
old_pw = request.form.get('old_password', '')
|
|
255
|
+
new_pw = request.form.get('new_password', '')
|
|
256
|
+
confirm_pw = request.form.get('confirm_new_password', '')
|
|
257
|
+
if not check_password_hash(user.password, old_pw):
|
|
258
|
+
flash('Неверный текущий пароль', 'danger')
|
|
259
|
+
elif len(new_pw) < 8:
|
|
260
|
+
flash('Новый пароль должен содержать минимум 8 символов', 'danger')
|
|
261
|
+
elif new_pw != confirm_pw:
|
|
262
|
+
flash('Пароли не совпадают', 'danger')
|
|
263
|
+
else:
|
|
264
|
+
user.password = generate_password_hash(new_pw)
|
|
265
|
+
db.session.commit()
|
|
266
|
+
flash('Пароль успешно изменён', 'success')
|
|
267
|
+
else:
|
|
268
|
+
# Обновление данных профиля
|
|
269
|
+
if hasattr(user, 'full_name'):
|
|
270
|
+
user.full_name = request.form.get('full_name', user.full_name)
|
|
271
|
+
if hasattr(user, 'email'):
|
|
272
|
+
user.email = request.form.get('email', user.email)
|
|
273
|
+
if hasattr(user, 'phone'):
|
|
274
|
+
phone_raw = request.form.get('phone', '')
|
|
275
|
+
user.phone = re.sub(r'\D', '', phone_raw) if phone_raw else user.phone
|
|
276
|
+
if hasattr(user, 'birth_date'):
|
|
277
|
+
user.birth_date = request.form.get('birth_date', user.birth_date)
|
|
278
|
+
if hasattr(user, 'address'):
|
|
279
|
+
user.address = request.form.get('address', user.address)
|
|
280
|
+
if hasattr(user, 'gender'):
|
|
281
|
+
user.gender = request.form.get('gender', user.gender)
|
|
282
|
+
if hasattr(user, 'city'):
|
|
283
|
+
user.city = request.form.get('city', user.city)
|
|
284
|
+
if hasattr(user, 'workplace'):
|
|
285
|
+
user.workplace = request.form.get('workplace', user.workplace)
|
|
286
|
+
if hasattr(user, 'inn'):
|
|
287
|
+
user.inn = request.form.get('inn', user.inn)
|
|
288
|
+
if hasattr(user, 'snils'):
|
|
289
|
+
user.snils = request.form.get('snils', user.snils)
|
|
290
|
+
if hasattr(user, 'education'):
|
|
291
|
+
user.education = request.form.get('education', user.education)
|
|
292
|
+
if hasattr(user, 'position'):
|
|
293
|
+
user.position = request.form.get('position', user.position)
|
|
294
|
+
if hasattr(user, 'telegram'):
|
|
295
|
+
user.telegram = request.form.get('telegram', user.telegram)
|
|
296
|
+
|
|
297
|
+
# Аватар
|
|
298
|
+
avatar_file = request.files.get('avatar')
|
|
299
|
+
if avatar_file and avatar_file.filename:
|
|
300
|
+
if avatar_file.content_length and avatar_file.content_length > 2 * 1024 * 1024:
|
|
301
|
+
flash('Размер аватара не должен превышать 2MB', 'danger')
|
|
302
|
+
else:
|
|
303
|
+
saved = save_uploaded_file(avatar_file, 'avatars')
|
|
304
|
+
if saved:
|
|
305
|
+
user.avatar = saved
|
|
306
|
+
else:
|
|
307
|
+
flash('Недопустимый формат файла (только jpg, png)', 'danger')
|
|
308
|
+
|
|
309
|
+
db.session.commit()
|
|
310
|
+
flash('Профиль обновлён', 'success')
|
|
311
|
+
|
|
312
|
+
return render_template('profile.html', user=user)
|
|
313
|
+
|
|
314
|
+
# ─────────────────────────────────────────────
|
|
315
|
+
# ЗАЯВКИ
|
|
316
|
+
# ─────────────────────────────────────────────
|
|
317
|
+
@app.route('/new_request', methods=['GET', 'POST'])
|
|
318
|
+
@login_required
|
|
319
|
+
def new_request():
|
|
320
|
+
if request.method == 'POST':
|
|
321
|
+
extra_data = {}
|
|
322
|
+
for key in request.form:
|
|
323
|
+
if key not in ['csrf_token']:
|
|
324
|
+
extra_data[key] = request.form[key]
|
|
325
|
+
|
|
326
|
+
req = Request(
|
|
327
|
+
user_id=session['user_id'],
|
|
328
|
+
status=STATUS_LIST[0],
|
|
329
|
+
**extra_data
|
|
330
|
+
)
|
|
331
|
+
db.session.add(req)
|
|
332
|
+
db.session.flush() # получаем id до commit
|
|
333
|
+
|
|
334
|
+
{{PHOTO_BEFORE_UPLOAD}}
|
|
335
|
+
|
|
336
|
+
db.session.commit()
|
|
337
|
+
flash('Заявка успешно создана!', 'success')
|
|
338
|
+
return redirect(url_for('dashboard'))
|
|
339
|
+
|
|
340
|
+
return render_template('new_request.html')
|
|
341
|
+
|
|
342
|
+
@app.route('/delete_request/<int:req_id>', methods=['POST'])
|
|
343
|
+
@login_required
|
|
344
|
+
def delete_request(req_id):
|
|
345
|
+
req = Request.query.get_or_404(req_id)
|
|
346
|
+
if req.user_id == session['user_id'] and req.status == STATUS_LIST[0]:
|
|
347
|
+
db.session.delete(req)
|
|
348
|
+
db.session.commit()
|
|
349
|
+
flash('Заявка удалена', 'info')
|
|
350
|
+
return redirect(url_for('dashboard'))
|
|
351
|
+
|
|
352
|
+
# ─────────────────────────────────────────────
|
|
353
|
+
# АДМИН-ПАНЕЛЬ
|
|
354
|
+
# ─────────────────────────────────────────────
|
|
355
|
+
@app.route('/admin')
|
|
356
|
+
@admin_required
|
|
357
|
+
def admin():
|
|
358
|
+
{{ADMIN_ROUTE_BODY}}
|
|
359
|
+
|
|
360
|
+
@app.route('/change_status/<int:req_id>', methods=['POST'])
|
|
361
|
+
@admin_required
|
|
362
|
+
def change_status(req_id):
|
|
363
|
+
req = Request.query.get_or_404(req_id)
|
|
364
|
+
new_status = request.form.get('new_status', '')
|
|
365
|
+
if new_status in STATUS_LIST:
|
|
366
|
+
req.status = new_status
|
|
367
|
+
{{PHOTO_AFTER_UPLOAD}}
|
|
368
|
+
db.session.commit()
|
|
369
|
+
flash(f'Статус заявки #{req_id} изменён на «{new_status}»', 'success')
|
|
370
|
+
return redirect(url_for('admin'))
|
|
371
|
+
|
|
372
|
+
@app.route('/bulk_status', methods=['POST'])
|
|
373
|
+
@admin_required
|
|
374
|
+
def bulk_status():
|
|
375
|
+
"""Массовое изменение статуса"""
|
|
376
|
+
ids = request.form.getlist('request_ids')
|
|
377
|
+
new_status = request.form.get('bulk_status', '')
|
|
378
|
+
if new_status in STATUS_LIST and ids:
|
|
379
|
+
for rid in ids:
|
|
380
|
+
req = Request.query.get(int(rid))
|
|
381
|
+
if req:
|
|
382
|
+
req.status = new_status
|
|
383
|
+
db.session.commit()
|
|
384
|
+
flash(f'Статус изменён для {len(ids)} заявок', 'success')
|
|
385
|
+
return redirect(url_for('admin'))
|
|
386
|
+
|
|
387
|
+
@app.route('/admin/delete_request/<int:req_id>', methods=['POST'])
|
|
388
|
+
@admin_required
|
|
389
|
+
def admin_delete_request(req_id):
|
|
390
|
+
req = Request.query.get_or_404(req_id)
|
|
391
|
+
db.session.delete(req)
|
|
392
|
+
db.session.commit()
|
|
393
|
+
flash('Заявка удалена', 'info')
|
|
394
|
+
return redirect(url_for('admin'))
|
|
395
|
+
|
|
396
|
+
@app.route('/admin/users')
|
|
397
|
+
@admin_required
|
|
398
|
+
def admin_users():
|
|
399
|
+
users = User.query.order_by(User.created_at.desc()).all()
|
|
400
|
+
return render_template('admin_users.html', users=users)
|
|
401
|
+
|
|
402
|
+
@app.route('/admin/block_user/<int:user_id>', methods=['POST'])
|
|
403
|
+
@admin_required
|
|
404
|
+
def block_user(user_id):
|
|
405
|
+
user = User.query.get_or_404(user_id)
|
|
406
|
+
if user.role != 'admin':
|
|
407
|
+
user.is_blocked = not user.is_blocked
|
|
408
|
+
db.session.commit()
|
|
409
|
+
state = 'заблокирован' if user.is_blocked else 'разблокирован'
|
|
410
|
+
flash(f'Пользователь {user.login} {state}', 'info')
|
|
411
|
+
return redirect(url_for('admin_users'))
|
|
412
|
+
|
|
413
|
+
@app.route('/admin/delete_user/<int:user_id>', methods=['POST'])
|
|
414
|
+
@admin_required
|
|
415
|
+
def delete_user(user_id):
|
|
416
|
+
user = User.query.get_or_404(user_id)
|
|
417
|
+
if user.role != 'admin':
|
|
418
|
+
db.session.delete(user)
|
|
419
|
+
db.session.commit()
|
|
420
|
+
flash(f'Пользователь удалён', 'info')
|
|
421
|
+
return redirect(url_for('admin_users'))
|
|
422
|
+
|
|
423
|
+
{{CART_ROUTES}}{{CATALOG_ROUTES}}{{REVIEW_ROUTES}}{{SERVICE_ROUTES}}
|
|
424
|
+
|
|
425
|
+
if __name__ == '__main__':
|
|
426
|
+
with app.app_context():
|
|
427
|
+
db.create_all()
|
|
428
|
+
|
|
429
|
+
# Создаём администратора если нет
|
|
430
|
+
{{ADMIN_CREATE_BLOCK}}
|
|
431
|
+
|
|
432
|
+
{{DEMO_DATA}}
|
|
433
|
+
|
|
434
|
+
app.run(debug=True, host='0.0.0.0', port=5000)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="ru">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
|
|
6
|
+
<title>{% block title %}Мой сайт{% endblock %}</title>
|
|
7
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
8
|
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
|
9
|
+
<style>
|
|
10
|
+
/* Глобальный фикс дёрганья модальных окон */
|
|
11
|
+
html { overflow-y: scroll; }
|
|
12
|
+
body { padding-right: 0 !important; }
|
|
13
|
+
body.modal-open { padding-right: 0 !important; overflow: auto !important; }
|
|
14
|
+
.modal { padding-right: 0 !important; }
|
|
15
|
+
.modal-backdrop { width: 100% !important; }
|
|
16
|
+
.modal-content { transform: none !important; transition: none !important; }
|
|
17
|
+
</style>
|
|
18
|
+
{% block extra_head %}{% endblock %}
|
|
19
|
+
</head>
|
|
20
|
+
<body>
|
|
21
|
+
<!-- Навигация -->
|
|
22
|
+
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top">
|
|
23
|
+
<div class="container">
|
|
24
|
+
<a class="navbar-brand fw-bold" href="{{ url_for('index') }}">
|
|
25
|
+
<span class="text-primary">●</span> Мой сайт
|
|
26
|
+
</a>
|
|
27
|
+
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
|
|
28
|
+
aria-controls="navbarNav" aria-expanded="false" aria-label="Меню">
|
|
29
|
+
<span class="navbar-toggler-icon"></span>
|
|
30
|
+
</button>
|
|
31
|
+
<div class="collapse navbar-collapse" id="navbarNav">
|
|
32
|
+
<ul class="navbar-nav ms-auto align-items-lg-center">
|
|
33
|
+
<li class="nav-item">
|
|
34
|
+
<a class="nav-link" href="{{ url_for('index') }}">Главная</a>
|
|
35
|
+
</li>
|
|
36
|
+
{% if session.user_id %}
|
|
37
|
+
{% if session.role != 'admin' %}
|
|
38
|
+
<li class="nav-item">
|
|
39
|
+
<a class="nav-link" href="{{ url_for('dashboard') }}">Личный кабинет</a>
|
|
40
|
+
</li>
|
|
41
|
+
<li class="nav-item">
|
|
42
|
+
<a class="nav-link" href="{{ url_for('profile') }}">Профиль</a>
|
|
43
|
+
</li>
|
|
44
|
+
{% endif %}
|
|
45
|
+
{% if session.role == 'admin' %}
|
|
46
|
+
<li class="nav-item">
|
|
47
|
+
<a class="nav-link text-warning" href="{{ url_for('admin') }}">👑 Админ</a>
|
|
48
|
+
</li>
|
|
49
|
+
{% endif %}
|
|
50
|
+
{{SERVICES_NAV_ITEM}}
|
|
51
|
+
{% if session.role != 'admin' %}
|
|
52
|
+
{{CART_NAV_ITEM}}
|
|
53
|
+
{% endif %}
|
|
54
|
+
<li class="nav-item ms-lg-2">
|
|
55
|
+
<a class="btn btn-outline-light btn-sm" href="{{ url_for('logout') }}">Выход</a>
|
|
56
|
+
</li>
|
|
57
|
+
{% else %}
|
|
58
|
+
<li class="nav-item ms-lg-2">
|
|
59
|
+
<a class="btn btn-outline-light btn-sm me-2" href="{{ url_for('login') }}">Войти</a>
|
|
60
|
+
</li>
|
|
61
|
+
<li class="nav-item">
|
|
62
|
+
<a class="btn btn-primary btn-sm" href="{{ url_for('register') }}">Регистрация</a>
|
|
63
|
+
</li>
|
|
64
|
+
{% endif %}
|
|
65
|
+
</ul>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</nav>
|
|
69
|
+
|
|
70
|
+
<!-- Flash-сообщения -->
|
|
71
|
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
72
|
+
{% if messages %}
|
|
73
|
+
<div class="container mt-3">
|
|
74
|
+
{% for category, message in messages %}
|
|
75
|
+
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
|
76
|
+
{{ message }}
|
|
77
|
+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
|
|
78
|
+
</div>
|
|
79
|
+
{% endfor %}
|
|
80
|
+
</div>
|
|
81
|
+
{% endif %}
|
|
82
|
+
{% endwith %}
|
|
83
|
+
|
|
84
|
+
<!-- Основной контент -->
|
|
85
|
+
<main class="container mt-4 mb-5">
|
|
86
|
+
{% block content %}{% endblock %}
|
|
87
|
+
</main>
|
|
88
|
+
|
|
89
|
+
<!-- Подвал -->
|
|
90
|
+
<footer class="bg-dark text-white text-center py-3 mt-auto">
|
|
91
|
+
<div class="container">
|
|
92
|
+
<small>© 2024 Мой сайт. Все права защищены.</small>
|
|
93
|
+
</div>
|
|
94
|
+
</footer>
|
|
95
|
+
|
|
96
|
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
97
|
+
<script src="{{ url_for('static', filename='js/slider.js') }}"></script>
|
|
98
|
+
{% block extra_scripts %}{% endblock %}
|
|
99
|
+
</body>
|
|
100
|
+
</html>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}Корзина{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<h3 class="mb-4">🛒 Корзина</h3>
|
|
7
|
+
|
|
8
|
+
{% if items %}
|
|
9
|
+
<div class="row g-4">
|
|
10
|
+
<div class="col-lg-8">
|
|
11
|
+
<div class="card border-0 shadow-sm">
|
|
12
|
+
<div class="card-body p-0">
|
|
13
|
+
<div class="table-responsive">
|
|
14
|
+
<table class="table table-hover align-middle mb-0">
|
|
15
|
+
<thead class="table-light">
|
|
16
|
+
<tr>
|
|
17
|
+
<th>Товар</th>
|
|
18
|
+
<th style="width:130px;">Количество</th>
|
|
19
|
+
<th>Цена</th>
|
|
20
|
+
<th>Сумма</th>
|
|
21
|
+
<th></th>
|
|
22
|
+
</tr>
|
|
23
|
+
</thead>
|
|
24
|
+
<tbody>
|
|
25
|
+
{% for item in items %}
|
|
26
|
+
<tr>
|
|
27
|
+
<td>
|
|
28
|
+
<div class="d-flex align-items-center gap-3">
|
|
29
|
+
{% if item.product.image %}
|
|
30
|
+
<img src="{{ url_for('static', filename='uploads/products/' ~ item.product.image) }}"
|
|
31
|
+
width="56" height="56" style="object-fit:cover;border-radius:8px;" alt="">
|
|
32
|
+
{% else %}
|
|
33
|
+
<div style="width:56px;height:56px;background:#e9ecef;border-radius:8px;display:flex;align-items:center;justify-content:center;">
|
|
34
|
+
🛍️
|
|
35
|
+
</div>
|
|
36
|
+
{% endif %}
|
|
37
|
+
<div>
|
|
38
|
+
<strong>{{ item.product.name }}</strong>
|
|
39
|
+
{% if item.product.stock is defined %}
|
|
40
|
+
<br><small class="text-muted">На складе: {{ item.product.stock }}</small>
|
|
41
|
+
{% endif %}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</td>
|
|
45
|
+
<td>
|
|
46
|
+
<form method="POST" action="{{ url_for('cart_update', item_id=item.id) }}"
|
|
47
|
+
class="d-flex align-items-center gap-1">
|
|
48
|
+
<input type="number" name="quantity" value="{{ item.quantity }}"
|
|
49
|
+
min="1" max="{{ item.product.stock or 99 }}"
|
|
50
|
+
class="form-control form-control-sm" style="width:65px;"
|
|
51
|
+
onchange="this.form.submit()">
|
|
52
|
+
</form>
|
|
53
|
+
</td>
|
|
54
|
+
<td>{{ item.product.price }} ₽</td>
|
|
55
|
+
<td><strong>{{ item.product.price * item.quantity }} ₽</strong></td>
|
|
56
|
+
<td>
|
|
57
|
+
<form method="POST" action="{{ url_for('cart_remove', item_id=item.id) }}">
|
|
58
|
+
<button type="submit" class="btn btn-sm btn-outline-danger">✕</button>
|
|
59
|
+
</form>
|
|
60
|
+
</td>
|
|
61
|
+
</tr>
|
|
62
|
+
{% endfor %}
|
|
63
|
+
</tbody>
|
|
64
|
+
</table>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<!-- Итог -->
|
|
71
|
+
<div class="col-lg-4">
|
|
72
|
+
<div class="card border-0 shadow-sm">
|
|
73
|
+
<div class="card-header bg-success text-white">
|
|
74
|
+
<h5 class="mb-0">Итого</h5>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="card-body">
|
|
77
|
+
<div class="d-flex justify-content-between mb-2">
|
|
78
|
+
<span>Товаров:</span>
|
|
79
|
+
<strong>{{ items|sum(attribute='quantity') }} шт.</strong>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="d-flex justify-content-between mb-3">
|
|
82
|
+
<span>Сумма:</span>
|
|
83
|
+
<strong class="text-success fs-5">{{ total }} ₽</strong>
|
|
84
|
+
</div>
|
|
85
|
+
<a href="{{ url_for('checkout') }}" class="btn btn-success w-100 py-2">
|
|
86
|
+
✅ Оформить заказ
|
|
87
|
+
</a>
|
|
88
|
+
<a href="{{ url_for('catalog') }}" class="btn btn-outline-secondary w-100 mt-2">
|
|
89
|
+
← Продолжить покупки
|
|
90
|
+
</a>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
{% else %}
|
|
96
|
+
<div class="text-center py-5 text-muted">
|
|
97
|
+
<div class="fs-1">🛒</div>
|
|
98
|
+
<h5 class="mt-3">Корзина пуста</h5>
|
|
99
|
+
<p>Добавьте товары из каталога</p>
|
|
100
|
+
<a href="{{ url_for('catalog') }}" class="btn btn-primary">Перейти в каталог</a>
|
|
101
|
+
</div>
|
|
102
|
+
{% endif %}
|
|
103
|
+
{% endblock %}
|