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.
Files changed (33) hide show
  1. sqlalchemy_connection-2.0.1.dist-info/METADATA +26 -0
  2. sqlalchemy_connection-2.0.1.dist-info/RECORD +33 -0
  3. sqlalchemy_connection-2.0.1.dist-info/WHEEL +5 -0
  4. sqlalchemy_connection-2.0.1.dist-info/entry_points.txt +2 -0
  5. sqlalchemy_connection-2.0.1.dist-info/top_level.txt +1 -0
  6. sqlalchemy_connector/__init__.py +3 -0
  7. sqlalchemy_connector/_builder.py +425 -0
  8. sqlalchemy_connector/cli.py +200 -0
  9. sqlalchemy_connector/real_generator.py +2908 -0
  10. sqlalchemy_connector/templates/admin_cart_html_template.html +372 -0
  11. sqlalchemy_connector/templates/admin_html_template.html +364 -0
  12. sqlalchemy_connector/templates/admin_users_html_template.html +82 -0
  13. sqlalchemy_connector/templates/app_template.py +434 -0
  14. sqlalchemy_connector/templates/base_html_template.html +100 -0
  15. sqlalchemy_connector/templates/cart_html_template.html +103 -0
  16. sqlalchemy_connector/templates/catalog_html_template.html +98 -0
  17. sqlalchemy_connector/templates/checkout_html_template.html +70 -0
  18. sqlalchemy_connector/templates/dashboard_html_template.html +121 -0
  19. sqlalchemy_connector/templates/index_html_template.html +91 -0
  20. sqlalchemy_connector/templates/login_html_template.html +59 -0
  21. sqlalchemy_connector/templates/models_template.py +65 -0
  22. sqlalchemy_connector/templates/new_request_html_template.html +49 -0
  23. sqlalchemy_connector/templates/orders_html_template.html +65 -0
  24. sqlalchemy_connector/templates/product_form_html_template.html +142 -0
  25. sqlalchemy_connector/templates/product_html_template.html +131 -0
  26. sqlalchemy_connector/templates/profile_html_template.html +104 -0
  27. sqlalchemy_connector/templates/register_html_template.html +183 -0
  28. sqlalchemy_connector/templates/reviews_html_template.html +104 -0
  29. sqlalchemy_connector/templates/service_detail_html_template.html +67 -0
  30. sqlalchemy_connector/templates/service_form_html_template.html +86 -0
  31. sqlalchemy_connector/templates/services_html_template.html +47 -0
  32. sqlalchemy_connector/templates/slider_js_template.js +99 -0
  33. 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 %}