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,2908 @@
1
+ import os
2
+ import shutil
3
+ import pathlib
4
+
5
+ # Путь к директории с шаблонами
6
+ TEMPLATES_DIR = pathlib.Path(__file__).parent / "templates"
7
+
8
+
9
+ def read_template(filename):
10
+ """Читает файл шаблона из директории templates."""
11
+ path = TEMPLATES_DIR / filename
12
+ with open(path, "r", encoding="utf-8") as f:
13
+ return f.read()
14
+
15
+
16
+ # ─────────────────────────────────────────────
17
+ # Строки-блоки для моделей (без вложенных ''')
18
+ # ─────────────────────────────────────────────
19
+
20
+ def _build_product_model_code(config):
21
+ """Строит строку с классом Product на основе конфига."""
22
+ lines = [
23
+ "# ─────────────────────────────────────────────",
24
+ "# ТОВАР",
25
+ "# ─────────────────────────────────────────────",
26
+ "class Product(db.Model):",
27
+ " __tablename__ = 'products'",
28
+ "",
29
+ " id = db.Column(db.Integer, primary_key=True)",
30
+ " name = db.Column(db.String(200), nullable=False)",
31
+ " price = db.Column(db.Float, nullable=False, default=0.0)",
32
+ " description = db.Column(db.Text, nullable=True)",
33
+ " image = db.Column(db.String(256), nullable=True)",
34
+ " stock = db.Column(db.Integer, default=0)",
35
+ " sold_count = db.Column(db.Integer, default=0)",
36
+ " is_active = db.Column(db.Boolean, default=True)",
37
+ " created_at = db.Column(db.DateTime, default=datetime.utcnow)",
38
+ ]
39
+ # Кастомные поля товара
40
+ for field in config.get("custom_product_fields", []):
41
+ col_type = _field_type_to_sqlalchemy(field["type"])
42
+ lines.append(f" {field['name']} = db.Column({col_type}, nullable=True)")
43
+ lines += [
44
+ "",
45
+ " cart_items = db.relationship('CartItem', backref='product', lazy=True, cascade='all, delete-orphan')",
46
+ " order_items = db.relationship('OrderItem', backref='product', lazy=True)",
47
+ "",
48
+ " def __repr__(self):",
49
+ " return f'<Product {self.name}>'",
50
+ ]
51
+ return "\n".join(lines) + "\n"
52
+
53
+ CART_MODELS_CODE = (
54
+ "# ─────────────────────────────────────────────\n"
55
+ "# КОРЗИНА\n"
56
+ "# ─────────────────────────────────────────────\n"
57
+ "class CartItem(db.Model):\n"
58
+ " __tablename__ = 'cart_items'\n\n"
59
+ " id = db.Column(db.Integer, primary_key=True)\n"
60
+ " user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)\n"
61
+ " product_id = db.Column(db.Integer, db.ForeignKey('products.id'), nullable=False)\n"
62
+ " quantity = db.Column(db.Integer, default=1)\n"
63
+ " added_at = db.Column(db.DateTime, default=datetime.utcnow)\n\n"
64
+ " def __repr__(self):\n"
65
+ " return f'<CartItem user={self.user_id} product={self.product_id}>'\n"
66
+ )
67
+
68
+ def _build_order_models_code(config):
69
+ """Строит строку с классами Order и OrderItem на основе конфига."""
70
+ lines = [
71
+ "# ─────────────────────────────────────────────",
72
+ "# ЗАКАЗ",
73
+ "# ─────────────────────────────────────────────",
74
+ "class Order(db.Model):",
75
+ " __tablename__ = 'orders'",
76
+ "",
77
+ " id = db.Column(db.Integer, primary_key=True)",
78
+ " user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)",
79
+ " status = db.Column(db.String(50), default='Новый')",
80
+ " total_price = db.Column(db.Float, default=0.0)",
81
+ " created_at = db.Column(db.DateTime, default=datetime.utcnow)",
82
+ ]
83
+ # Добавляем поля оформления заказа (checkout_fields)
84
+ for field in config.get("checkout_fields", []):
85
+ col_type = _field_type_to_sqlalchemy(field["type"])
86
+ lines.append(f" {field['name']} = db.Column({col_type}, nullable=True)")
87
+ lines += [
88
+ "",
89
+ " items = db.relationship('OrderItem', backref='order', lazy=True, cascade='all, delete-orphan')",
90
+ "",
91
+ " def __repr__(self):",
92
+ " return f'<Order {self.id} [{self.status}]>'",
93
+ "",
94
+ "",
95
+ "class OrderItem(db.Model):",
96
+ " __tablename__ = 'order_items'",
97
+ "",
98
+ " id = db.Column(db.Integer, primary_key=True)",
99
+ " order_id = db.Column(db.Integer, db.ForeignKey('orders.id'), nullable=False)",
100
+ " product_id = db.Column(db.Integer, db.ForeignKey('products.id'), nullable=True)",
101
+ " product_name = db.Column(db.String(200), nullable=False)",
102
+ " price = db.Column(db.Float, nullable=False)",
103
+ " quantity = db.Column(db.Integer, default=1)",
104
+ "",
105
+ " def __repr__(self):",
106
+ " return f'<OrderItem {self.product_name} x{self.quantity}>'",
107
+ ]
108
+ return "\n".join(lines) + "\n"
109
+
110
+ # ─────────────────────────────────────────────
111
+ # Строка-блок для модели Service
112
+ # ─────────────────────────────────────────────
113
+
114
+ def _build_service_model_code(config):
115
+ """Строит строку с классом Service на основе конфига."""
116
+ lines = [
117
+ "# ─────────────────────────────────────────────",
118
+ "# УСЛУГА",
119
+ "# ─────────────────────────────────────────────",
120
+ "class Service(db.Model):",
121
+ " __tablename__ = 'services'",
122
+ "",
123
+ " id = db.Column(db.Integer, primary_key=True)",
124
+ " name = db.Column(db.String(200), nullable=False)",
125
+ " created_at = db.Column(db.DateTime, default=datetime.utcnow)",
126
+ ]
127
+ if config.get("svc_description"):
128
+ lines.append(" description = db.Column(db.Text, nullable=True)")
129
+ if config.get("svc_price"):
130
+ lines.append(" price = db.Column(db.Float, nullable=True)")
131
+ if config.get("svc_duration"):
132
+ lines.append(" duration = db.Column(db.String(100), nullable=True)")
133
+ if config.get("svc_category"):
134
+ lines.append(" category = db.Column(db.String(100), nullable=True)")
135
+ if config.get("svc_image"):
136
+ lines.append(" image = db.Column(db.String(256), nullable=True)")
137
+ if config.get("svc_max_clients"):
138
+ lines.append(" max_clients = db.Column(db.Integer, nullable=True)")
139
+ if config.get("svc_location"):
140
+ lines.append(" location = db.Column(db.String(200), nullable=True)")
141
+ if config.get("svc_requirements"):
142
+ lines.append(" requirements = db.Column(db.Text, nullable=True)")
143
+ if config.get("svc_is_active"):
144
+ lines.append(" is_active = db.Column(db.Boolean, default=True)")
145
+ lines += [
146
+ "",
147
+ " requests = db.relationship('Request', backref='service', lazy=True)",
148
+ "",
149
+ " def __repr__(self):",
150
+ " return f'<Service {self.name}>'",
151
+ ]
152
+ return "\n".join(lines)
153
+
154
+
155
+ # Модель Review когда есть корзина с товарами (product_id → products.id)
156
+ REVIEW_MODEL_CODE_WITH_PRODUCT = (
157
+ "# ─────────────────────────────────────────────\n"
158
+ "# ОТЗЫВ\n"
159
+ "# ─────────────────────────────────────────────\n"
160
+ "class Review(db.Model):\n"
161
+ " __tablename__ = 'reviews'\n\n"
162
+ " id = db.Column(db.Integer, primary_key=True)\n"
163
+ " user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)\n"
164
+ " product_id = db.Column(db.Integer, db.ForeignKey('products.id'), nullable=True)\n"
165
+ " rating = db.Column(db.Integer, default=5)\n"
166
+ " text = db.Column(db.Text, nullable=True)\n"
167
+ " created_at = db.Column(db.DateTime, default=datetime.utcnow)\n\n"
168
+ " reviewer = db.relationship('User', backref=db.backref('reviews', lazy=True))\n\n"
169
+ " def __repr__(self):\n"
170
+ " return f'<Review {self.id} rating={self.rating}>'\n"
171
+ )
172
+
173
+ # Модель Review без привязки к товарам (отзывы на услуги)
174
+ REVIEW_MODEL_CODE_NO_PRODUCT = (
175
+ "# ─────────────────────────────────────────────\n"
176
+ "# ОТЗЫВ\n"
177
+ "# ─────────────────────────────────────────────\n"
178
+ "class Review(db.Model):\n"
179
+ " __tablename__ = 'reviews'\n\n"
180
+ " id = db.Column(db.Integer, primary_key=True)\n"
181
+ " user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)\n"
182
+ " service_id = db.Column(db.Integer, db.ForeignKey('services.id'), nullable=True)\n"
183
+ " rating = db.Column(db.Integer, default=5)\n"
184
+ " text = db.Column(db.Text, nullable=True)\n"
185
+ " admin_reply = db.Column(db.Text, nullable=True)\n"
186
+ " created_at = db.Column(db.DateTime, default=datetime.utcnow)\n\n"
187
+ " reviewer = db.relationship('User', backref=db.backref('reviews', lazy=True))\n"
188
+ " service = db.relationship('Service', backref=db.backref('reviews', lazy=True))\n\n"
189
+ " def __repr__(self):\n"
190
+ " return f'<Review {self.id} rating={self.rating}>'\n"
191
+ )
192
+
193
+ # Модель Review общая (без привязки к товарам и услугам)
194
+ REVIEW_MODEL_CODE_GENERIC = (
195
+ "# ─────────────────────────────────────────────\n"
196
+ "# ОТЗЫВ\n"
197
+ "# ─────────────────────────────────────────────\n"
198
+ "class Review(db.Model):\n"
199
+ " __tablename__ = 'reviews'\n\n"
200
+ " id = db.Column(db.Integer, primary_key=True)\n"
201
+ " user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)\n"
202
+ " rating = db.Column(db.Integer, default=5)\n"
203
+ " text = db.Column(db.Text, nullable=True)\n"
204
+ " admin_reply = db.Column(db.Text, nullable=True)\n"
205
+ " created_at = db.Column(db.DateTime, default=datetime.utcnow)\n\n"
206
+ " reviewer = db.relationship('User', backref=db.backref('reviews', lazy=True))\n\n"
207
+ " def __repr__(self):\n"
208
+ " return f'<Review {self.id} rating={self.rating}>'\n"
209
+ )
210
+
211
+
212
+ def _field_type_to_sqlalchemy(field_type):
213
+ """Конвертирует тип поля из CLI в тип SQLAlchemy."""
214
+ mapping = {
215
+ "text": "db.String(300)",
216
+ "textarea": "db.Text",
217
+ "email": "db.String(200)",
218
+ "phone": "db.String(30)",
219
+ "date": "db.String(20)",
220
+ "time": "db.String(10)",
221
+ "datetime": "db.String(30)",
222
+ "number": "db.Integer",
223
+ "select": "db.String(200)",
224
+ }
225
+ return mapping.get(field_type, "db.String(300)")
226
+
227
+
228
+ def _field_to_html_input(field, prefix=""):
229
+ """Генерирует HTML-код поля формы на основе описания поля."""
230
+ name = field["name"]
231
+ label = field["label"]
232
+ ftype = field["type"]
233
+ required = field.get("required", False)
234
+ options = field.get("options", [])
235
+ req_attr = ' required' if required else ''
236
+ req_star = ' <span class="text-danger">*</span>' if required else ''
237
+
238
+ if ftype == "textarea":
239
+ return (
240
+ f' <div class="mb-3">\n'
241
+ f' <label for="{prefix}{name}" class="form-label fw-semibold">{label}{req_star}</label>\n'
242
+ f' <textarea class="form-control" id="{prefix}{name}" name="{name}" rows="3"{req_attr}></textarea>\n'
243
+ f' </div>'
244
+ )
245
+ elif ftype == "select" and options:
246
+ opts_html = ' <option value="">— Выберите —</option>\n'
247
+ for opt in options:
248
+ opts_html += f' <option value="{opt}">{opt}</option>\n'
249
+ return (
250
+ f' <div class="mb-3">\n'
251
+ f' <label for="{prefix}{name}" class="form-label fw-semibold">{label}{req_star}</label>\n'
252
+ f' <select class="form-select" id="{prefix}{name}" name="{name}"{req_attr}>\n'
253
+ f'{opts_html}'
254
+ f' </select>\n'
255
+ f' </div>'
256
+ )
257
+ elif ftype == "date":
258
+ return (
259
+ f' <div class="mb-3">\n'
260
+ f' <label for="{prefix}{name}" class="form-label fw-semibold">{label}{req_star}</label>\n'
261
+ f' <input type="date" class="form-control" id="{prefix}{name}" name="{name}"{req_attr}>\n'
262
+ f' </div>'
263
+ )
264
+ elif ftype == "time":
265
+ return (
266
+ f' <div class="mb-3">\n'
267
+ f' <label for="{prefix}{name}" class="form-label fw-semibold">{label}{req_star}</label>\n'
268
+ f' <input type="time" class="form-control" id="{prefix}{name}" name="{name}"{req_attr}>\n'
269
+ f' </div>'
270
+ )
271
+ elif ftype == "datetime":
272
+ return (
273
+ f' <div class="mb-3">\n'
274
+ f' <label for="{prefix}{name}" class="form-label fw-semibold">{label}{req_star}</label>\n'
275
+ f' <input type="datetime-local" class="form-control" id="{prefix}{name}" name="{name}"{req_attr}>\n'
276
+ f' </div>'
277
+ )
278
+ elif ftype == "number":
279
+ return (
280
+ f' <div class="mb-3">\n'
281
+ f' <label for="{prefix}{name}" class="form-label fw-semibold">{label}{req_star}</label>\n'
282
+ f' <input type="number" class="form-control" id="{prefix}{name}" name="{name}"{req_attr}>\n'
283
+ f' </div>'
284
+ )
285
+ elif ftype == "email":
286
+ return (
287
+ f' <div class="mb-3">\n'
288
+ f' <label for="{prefix}{name}" class="form-label fw-semibold">{label}{req_star}</label>\n'
289
+ f' <input type="email" class="form-control" id="{prefix}{name}" name="{name}"{req_attr}>\n'
290
+ f' </div>'
291
+ )
292
+ elif ftype == "phone":
293
+ return (
294
+ f' <div class="mb-3">\n'
295
+ f' <label for="{prefix}{name}" class="form-label fw-semibold">{label}{req_star}</label>\n'
296
+ f' <input type="tel" class="form-control" id="{prefix}{name}" name="{name}" placeholder="+7 (999) 999-99-99"{req_attr}>\n'
297
+ f' </div>'
298
+ )
299
+ else: # text
300
+ return (
301
+ f' <div class="mb-3">\n'
302
+ f' <label for="{prefix}{name}" class="form-label fw-semibold">{label}{req_star}</label>\n'
303
+ f' <input type="text" class="form-control" id="{prefix}{name}" name="{name}"{req_attr}>\n'
304
+ f' </div>'
305
+ )
306
+
307
+
308
+ def build_models(config):
309
+ """Генерирует models.py на основе конфигурации."""
310
+ tpl = read_template("models_template.py")
311
+
312
+ # Дополнительные поля пользователя
313
+ user_extra = []
314
+ if config.get("user_full_name"):
315
+ user_extra.append(" full_name = db.Column(db.String(200), nullable=True)")
316
+ if config.get("user_email"):
317
+ user_extra.append(" email = db.Column(db.String(200), nullable=True)")
318
+ if config.get("user_phone"):
319
+ user_extra.append(" phone = db.Column(db.String(20), nullable=True)")
320
+ if config.get("user_birth_date"):
321
+ user_extra.append(" birth_date = db.Column(db.String(20), nullable=True)")
322
+ if config.get("user_address"):
323
+ user_extra.append(" address = db.Column(db.String(300), nullable=True)")
324
+ if config.get("user_gender"):
325
+ user_extra.append(" gender = db.Column(db.String(10), nullable=True)")
326
+ if config.get("user_city"):
327
+ user_extra.append(" city = db.Column(db.String(100), nullable=True)")
328
+ if config.get("user_workplace"):
329
+ user_extra.append(" workplace = db.Column(db.String(200), nullable=True)")
330
+ if config.get("user_passport"):
331
+ user_extra.append(" passport = db.Column(db.String(20), nullable=True)")
332
+ if config.get("user_inn"):
333
+ user_extra.append(" inn = db.Column(db.String(12), nullable=True)")
334
+ if config.get("user_snils"):
335
+ user_extra.append(" snils = db.Column(db.String(14), nullable=True)")
336
+ if config.get("user_education"):
337
+ user_extra.append(" education = db.Column(db.String(300), nullable=True)")
338
+ if config.get("user_position"):
339
+ user_extra.append(" position = db.Column(db.String(200), nullable=True)")
340
+ if config.get("user_telegram"):
341
+ user_extra.append(" telegram = db.Column(db.String(100), nullable=True)")
342
+ # Кастомные поля пользователя
343
+ for field in config.get("custom_user_fields", []):
344
+ col_type = _field_type_to_sqlalchemy(field["type"])
345
+ user_extra.append(f" {field['name']} = db.Column({col_type}, nullable=True)")
346
+ tpl = tpl.replace(
347
+ "{{USER_EXTRA_FIELDS}}",
348
+ "\n".join(user_extra) if user_extra else " # нет доп. полей"
349
+ )
350
+
351
+ # Если авторизация по email — login необязателен, email уникален
352
+ if config.get("auth_by_email"):
353
+ tpl = tpl.replace(
354
+ "login = db.Column(db.String(80), unique=True, nullable=False)",
355
+ "login = db.Column(db.String(80), unique=True, nullable=True)"
356
+ )
357
+ # Делаем email уникальным и обязательным
358
+ tpl = tpl.replace(
359
+ " email = db.Column(db.String(200), nullable=True)",
360
+ " email = db.Column(db.String(200), unique=True, nullable=False)"
361
+ )
362
+
363
+ # Дополнительные поля заявки (кастомные)
364
+ req_extra = []
365
+ for field in config.get("custom_request_fields", []):
366
+ col_type = _field_type_to_sqlalchemy(field["type"])
367
+ req_extra.append(f" {field['name']} = db.Column({col_type}, nullable=True)")
368
+ tpl = tpl.replace(
369
+ "{{REQUEST_EXTRA_FIELDS}}",
370
+ "\n".join(req_extra) if req_extra else " # нет доп. полей"
371
+ )
372
+
373
+ # Фото до/после
374
+ if config.get("photo_before_after"):
375
+ photo_fields = (
376
+ " photo_before = db.Column(db.String(256), nullable=True)\n"
377
+ " photo_after = db.Column(db.String(256), nullable=True)"
378
+ )
379
+ else:
380
+ photo_fields = " # фото не используются"
381
+ tpl = tpl.replace("{{PHOTO_FIELDS}}", photo_fields)
382
+
383
+ # Модель Product
384
+ if config.get("cart") and config.get("cart_type") == "products":
385
+ tpl = tpl.replace("{{PRODUCT_MODEL}}", _build_product_model_code(config))
386
+ else:
387
+ tpl = tpl.replace("{{PRODUCT_MODEL}}", "# Модель Product не используется")
388
+
389
+ # Модели корзины
390
+ if config.get("cart"):
391
+ tpl = tpl.replace("{{CART_MODELS}}", CART_MODELS_CODE)
392
+ tpl = tpl.replace(
393
+ "{{CART_USER_RELATION}}",
394
+ " cart_items = db.relationship('CartItem', backref='cart_user', lazy=True, cascade='all, delete-orphan')"
395
+ )
396
+ else:
397
+ tpl = tpl.replace("{{CART_MODELS}}", "# Корзина не используется")
398
+ tpl = tpl.replace("{{CART_USER_RELATION}}", " # корзина не используется")
399
+
400
+ # Модели заказов
401
+ if config.get("cart"):
402
+ tpl = tpl.replace("{{ORDER_MODELS}}", _build_order_models_code(config))
403
+ tpl = tpl.replace(
404
+ "{{ORDER_USER_RELATION}}",
405
+ " orders = db.relationship('Order', backref='order_user', lazy=True, cascade='all, delete-orphan')"
406
+ )
407
+ else:
408
+ tpl = tpl.replace("{{ORDER_MODELS}}", "# Заказы не используются")
409
+ tpl = tpl.replace("{{ORDER_USER_RELATION}}", " # заказы не используются")
410
+
411
+ # Модель отзывов
412
+ # Если есть корзина с товарами — отзывы привязаны к products.id
413
+ # Если есть услуги (без корзины) — отзывы привязаны к services.id
414
+ # Если нет ни корзины, ни услуг — общие отзывы без FK на внешние таблицы
415
+ if config.get("reviews"):
416
+ if config.get("cart") and config.get("cart_type") == "products":
417
+ tpl = tpl.replace("{{REVIEW_MODEL}}", REVIEW_MODEL_CODE_WITH_PRODUCT)
418
+ elif config.get("services"):
419
+ tpl = tpl.replace("{{REVIEW_MODEL}}", REVIEW_MODEL_CODE_NO_PRODUCT)
420
+ else:
421
+ tpl = tpl.replace("{{REVIEW_MODEL}}", REVIEW_MODEL_CODE_GENERIC)
422
+ else:
423
+ tpl = tpl.replace("{{REVIEW_MODEL}}", "# Отзывы не используются")
424
+ tpl = tpl.replace("{{REVIEW_USER_RELATION}}", " # отзывы через backref в Review")
425
+
426
+ # Модель Service
427
+ if config.get("services"):
428
+ tpl = tpl.replace("{{SERVICE_MODEL}}", _build_service_model_code(config))
429
+ tpl = tpl.replace(
430
+ "{{SERVICE_FK}}",
431
+ " service_id = db.Column(db.Integer, db.ForeignKey('services.id'), nullable=True)"
432
+ )
433
+ else:
434
+ tpl = tpl.replace("{{SERVICE_MODEL}}", "# Модуль услуг не используется")
435
+ tpl = tpl.replace("{{SERVICE_FK}}", " # услуги не используются")
436
+
437
+ return tpl
438
+
439
+
440
+ def build_app(config):
441
+ """Генерирует app.py на основе конфигурации."""
442
+ tpl = read_template("app_template.py")
443
+
444
+ statuses = config.get("statuses", ["Новая", "В работе", "Завершена"])
445
+ admin_login = config.get("admin_login", "admin")
446
+ admin_password = config.get("admin_password", "admin123")
447
+
448
+ # STATUS_LIST
449
+ tpl = tpl.replace("{{STATUS_LIST}}", repr(statuses))
450
+
451
+ # Администратор — блок создания
452
+ admin_create_lines = []
453
+ if config.get("auth_by_email"):
454
+ admin_create_lines.append(f"if not User.query.filter_by(email='{admin_login}').first():")
455
+ admin_create_lines.append(f" admin_user = User(")
456
+ admin_create_lines.append(f" email='{admin_login}',")
457
+ admin_create_lines.append(f" password=generate_password_hash('{admin_password}'),")
458
+ admin_create_lines.append(f" role='admin'")
459
+ else:
460
+ admin_create_lines.append(f"if not User.query.filter_by(login='{admin_login}').first():")
461
+ admin_create_lines.append(f" admin_user = User(")
462
+ admin_create_lines.append(f" login='{admin_login}',")
463
+ admin_create_lines.append(f" password=generate_password_hash('{admin_password}'),")
464
+ admin_create_lines.append(f" role='admin'")
465
+ # Дополнительные поля админа
466
+ admin_extra = config.get("admin_extra", {})
467
+ for field_name, field_value in admin_extra.items():
468
+ if field_value:
469
+ admin_create_lines.append(f" ,{field_name}='{field_value}'")
470
+ admin_create_lines.append(f" )")
471
+ admin_create_lines.append(f" db.session.add(admin_user)")
472
+ admin_create_lines.append(f" db.session.commit()")
473
+ if config.get("auth_by_email"):
474
+ admin_create_lines.append(f" print('Администратор создан: email={admin_login}, пароль={admin_password}')")
475
+ else:
476
+ admin_create_lines.append(f" print('Администратор создан: логин={admin_login}, пароль={admin_password}')")
477
+ tpl = tpl.replace("{{ADMIN_CREATE_BLOCK}}", "\n ".join(admin_create_lines))
478
+
479
+ # Если авторизация по email — заменяем login на email в авторизации и регистрации
480
+ if config.get("auth_by_email"):
481
+ # В регистрации: убираем валидацию логина, используем email как основной идентификатор
482
+ tpl = tpl.replace(
483
+ "login_val = request.form.get('login', '').strip()",
484
+ "email_val = request.form.get('email', '').strip()"
485
+ )
486
+ tpl = tpl.replace(
487
+ " # Валидация логина\n"
488
+ " if not re.match(r'^[a-zA-Z0-9]{6,}$', login_val):\n"
489
+ " errors['login'] = 'Логин: только латиница и цифры, минимум 6 символов'\n"
490
+ " elif User.query.filter_by(login=login_val).first():\n"
491
+ " errors['login'] = 'Этот логин уже занят'",
492
+ " # Валидация email (обязательное поле для входа)\n"
493
+ " if not email_val:\n"
494
+ " errors['email'] = 'Email обязателен'\n"
495
+ " elif not re.match(r'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$', email_val):\n"
496
+ " errors['email'] = 'Введите корректный email'\n"
497
+ " elif User.query.filter_by(email=email_val).first():\n"
498
+ " errors['email'] = 'Этот email уже зарегистрирован'"
499
+ )
500
+ tpl = tpl.replace(
501
+ " user = User(\n"
502
+ " login=login_val,\n"
503
+ " password=generate_password_hash(password),\n"
504
+ " role='user',\n"
505
+ " **extra_data\n"
506
+ " )",
507
+ " user = User(\n"
508
+ " email=email_val,\n"
509
+ " password=generate_password_hash(password),\n"
510
+ " role='user',\n"
511
+ " **extra_data\n"
512
+ " )"
513
+ )
514
+ # check_login → check_email
515
+ tpl = tpl.replace(
516
+ "@app.route('/check_login')\n"
517
+ "def check_login():\n"
518
+ " \"\"\"AJAX-проверка уникальности логина\"\"\"\n"
519
+ " login_val = request.args.get('login', '').strip()\n"
520
+ " exists = User.query.filter_by(login=login_val).first() is not None\n"
521
+ " return jsonify({'exists': exists})",
522
+ "@app.route('/check_email')\n"
523
+ "def check_email():\n"
524
+ " \"\"\"AJAX-проверка уникальности email\"\"\"\n"
525
+ " email_val = request.args.get('email', '').strip()\n"
526
+ " exists = User.query.filter_by(email=email_val).first() is not None\n"
527
+ " return jsonify({'exists': exists})"
528
+ )
529
+ # Авторизация по email — после первой замены login_val→email_val,
530
+ # в login() осталось filter_by(login=login_val) → нужно заменить
531
+ tpl = tpl.replace(
532
+ " user = User.query.filter_by(login=login_val).first()",
533
+ " user = User.query.filter_by(email=email_val).first()"
534
+ )
535
+ tpl = tpl.replace(
536
+ " session['login'] = user.login",
537
+ " session['login'] = user.email"
538
+ )
539
+ tpl = tpl.replace(
540
+ " flash('Неверный логин или пароль', 'danger')",
541
+ " flash('Неверный email или пароль', 'danger')"
542
+ )
543
+ # Убираем email из extra_data (он уже передаётся напрямую в User())
544
+ tpl = tpl.replace(
545
+ " if email_val:\n"
546
+ " extra_data['email'] = email_val",
547
+ " # email передаётся напрямую, не через extra_data"
548
+ )
549
+
550
+ # ── Фото до/после ──────────────────────────────────────────
551
+ if config.get("photo_before_after"):
552
+ before_after_index_query = (
553
+ " completed_requests = Request.query.filter(\n"
554
+ " Request.status == STATUS_LIST[-1],\n"
555
+ " Request.photo_before != None,\n"
556
+ " Request.photo_after != None\n"
557
+ " ).order_by(Request.created_at.desc()).limit(5).all()"
558
+ )
559
+ before_after_index_pass = ", completed_requests=completed_requests"
560
+ photo_before_upload = (
561
+ " photo_before_file = request.files.get('photo_before')\n"
562
+ " if photo_before_file and photo_before_file.filename:\n"
563
+ " saved = save_uploaded_file(photo_before_file, 'requests')\n"
564
+ " if saved:\n"
565
+ " req.photo_before = saved"
566
+ )
567
+ photo_after_upload = (
568
+ " if new_status == STATUS_LIST[-1]:\n"
569
+ " photo_after_file = request.files.get('photo_after')\n"
570
+ " if photo_after_file and photo_after_file.filename:\n"
571
+ " saved = save_uploaded_file(photo_after_file, 'requests')\n"
572
+ " if saved:\n"
573
+ " req.photo_after = saved"
574
+ )
575
+ else:
576
+ before_after_index_query = ""
577
+ before_after_index_pass = ""
578
+ photo_before_upload = " pass # фото до/после не используется"
579
+ photo_after_upload = " pass # фото до/после не используется"
580
+
581
+ tpl = tpl.replace(" {{BEFORE_AFTER_INDEX_QUERY}}", before_after_index_query)
582
+ tpl = tpl.replace("{{BEFORE_AFTER_INDEX_QUERY}}", before_after_index_query)
583
+ tpl = tpl.replace("{{BEFORE_AFTER_INDEX_PASS}}", before_after_index_pass)
584
+ tpl = tpl.replace(" {{PHOTO_BEFORE_UPLOAD}}", photo_before_upload)
585
+ tpl = tpl.replace("{{PHOTO_BEFORE_UPLOAD}}", photo_before_upload)
586
+ tpl = tpl.replace(" {{PHOTO_AFTER_UPLOAD}}", photo_after_upload)
587
+ tpl = tpl.replace("{{PHOTO_AFTER_UPLOAD}}", photo_after_upload)
588
+
589
+ # ── Строим список импортируемых моделей ─────────────────────
590
+ imports = ["db", "User", "Request"]
591
+ if config.get("cart") and config.get("cart_type") == "products":
592
+ imports += ["Product", "CartItem", "Order", "OrderItem"]
593
+ cart_routes = _cart_routes_code(config)
594
+ catalog_routes = _catalog_routes_code()
595
+ else:
596
+ cart_routes = ""
597
+ catalog_routes = ""
598
+
599
+ if config.get("reviews"):
600
+ if "Review" not in imports:
601
+ imports.append("Review")
602
+ # Маршруты отзывов:
603
+ # - корзина с товарами (без услуг) → отзывы на товары
604
+ # - услуги → отзывы генерируются внутри _service_routes_code
605
+ # - ни корзины, ни услуг → общие отзывы (на главной/дашборде)
606
+ if config.get("cart") and config.get("cart_type") == "products" and not config.get("services"):
607
+ review_routes = _review_routes_code()
608
+ elif not config.get("services"):
609
+ review_routes = _generic_review_routes_code()
610
+ else:
611
+ review_routes = ""
612
+ else:
613
+ review_routes = ""
614
+
615
+ if config.get("services"):
616
+ service_routes = _service_routes_code(config)
617
+ if "Service" not in imports:
618
+ imports.append("Service")
619
+ else:
620
+ service_routes = ""
621
+
622
+ # Заменяем строку импорта — ищем любую строку "from models import ..."
623
+ new_import_line = "from models import " + ", ".join(imports)
624
+ lines_out = []
625
+ for line in tpl.split("\n"):
626
+ if line.startswith("from models import"):
627
+ lines_out.append(new_import_line)
628
+ else:
629
+ lines_out.append(line)
630
+ tpl = "\n".join(lines_out)
631
+
632
+ # ── {{CHECKOUT_EXTRA_FIELDS_SAVE}} ────────────────────────────────
633
+ checkout_save_lines = []
634
+ for field in config.get("checkout_fields", []):
635
+ name = field["name"]
636
+ checkout_save_lines.append(f" order.{name} = request.form.get('{name}', '').strip()")
637
+ tpl = tpl.replace(
638
+ " {{CHECKOUT_EXTRA_FIELDS_SAVE}}",
639
+ "\n".join(checkout_save_lines) if checkout_save_lines else " pass # нет доп. полей оформления"
640
+ )
641
+
642
+ # ── {{ADMIN_ROUTE_BODY}} ───────────────────────────────────────────
643
+ if config.get("cart") and config.get("cart_type") == "products":
644
+ admin_route_body_lines = [
645
+ " from collections import defaultdict",
646
+ " from datetime import date, timedelta as td",
647
+ "",
648
+ " total_orders = Order.query.count()",
649
+ " total_revenue = db.session.query(db.func.coalesce(db.func.sum(Order.total_price), 0)).scalar()",
650
+ " total_products = Product.query.count()",
651
+ " total_users = User.query.count()",
652
+ "",
653
+ " # Последние заказы",
654
+ " recent_orders = Order.query.order_by(Order.created_at.desc()).limit(10).all()",
655
+ "",
656
+ " # Популярные товары (по sold_count)",
657
+ " popular_products = Product.query.order_by(Product.sold_count.desc()).limit(5).all()",
658
+ "",
659
+ " # Статистика по статусам заказов",
660
+ " order_stats = {}",
661
+ " for s in STATUS_LIST:",
662
+ " order_stats[s] = Order.query.filter_by(status=s).count()",
663
+ "",
664
+ " # Выручка за 7 дней",
665
+ " revenue_by_day = {}",
666
+ " today = date.today()",
667
+ " for i in range(6, -1, -1):",
668
+ " d = today - td(days=i)",
669
+ " day_str = d.strftime('%d.%m')",
670
+ " day_sum = db.session.query(db.func.coalesce(db.func.sum(Order.total_price), 0)).filter(",
671
+ " db.func.date(Order.created_at) == d",
672
+ " ).scalar()",
673
+ " revenue_by_day[day_str] = float(day_sum)",
674
+ "",
675
+ " return render_template('admin.html',",
676
+ " total_orders=total_orders,",
677
+ " total_revenue=total_revenue,",
678
+ " total_products=total_products,",
679
+ " total_users=total_users,",
680
+ " recent_orders=recent_orders,",
681
+ " popular_products=popular_products,",
682
+ " order_stats=order_stats,",
683
+ " order_statuses=STATUS_LIST,",
684
+ " revenue_by_day=revenue_by_day)",
685
+ ]
686
+ else:
687
+ admin_route_body_lines = [
688
+ " page = request.args.get('page', 1, type=int)",
689
+ " per_page = request.args.get('per_page', 10, type=int)",
690
+ " status_filter = request.args.get('status', 'all')",
691
+ " search_query = request.args.get('search', '').strip()",
692
+ " date_from = request.args.get('date_from', '')",
693
+ " date_to = request.args.get('date_to', '')",
694
+ "",
695
+ " query = Request.query",
696
+ "",
697
+ " if status_filter and status_filter != 'all':",
698
+ " query = query.filter(Request.status == status_filter)",
699
+ "",
700
+ " if search_query:",
701
+ " search_like = f'%{search_query}%'",
702
+ " filters = []",
703
+ " if hasattr(Request, 'description'):",
704
+ " filters.append(Request.description.ilike(search_like))",
705
+ " if hasattr(Request, 'service_name'):",
706
+ " filters.append(Request.service_name.ilike(search_like))",
707
+ " if filters:",
708
+ " from sqlalchemy import or_",
709
+ " query = query.filter(or_(*filters))",
710
+ "",
711
+ " if date_from:",
712
+ " try:",
713
+ " df = datetime.strptime(date_from, '%Y-%m-%d')",
714
+ " query = query.filter(Request.created_at >= df)",
715
+ " except ValueError:",
716
+ " pass",
717
+ "",
718
+ " if date_to:",
719
+ " try:",
720
+ " dt = datetime.strptime(date_to, '%Y-%m-%d') + timedelta(days=1)",
721
+ " query = query.filter(Request.created_at < dt)",
722
+ " except ValueError:",
723
+ " pass",
724
+ "",
725
+ " total = query.count()",
726
+ " requests_page = query.order_by(Request.created_at.desc()).offset((page - 1) * per_page).limit(per_page).all()",
727
+ " total_pages = (total + per_page - 1) // per_page",
728
+ "",
729
+ " stats = {}",
730
+ " for s in STATUS_LIST:",
731
+ " stats[s] = Request.query.filter_by(status=s).count()",
732
+ "",
733
+ " users = User.query.order_by(User.created_at.desc()).all()",
734
+ "",
735
+ " return render_template('admin.html',",
736
+ " requests=requests_page,",
737
+ " status_list=STATUS_LIST,",
738
+ " stats=stats,",
739
+ " users=users,",
740
+ " page=page,",
741
+ " per_page=per_page,",
742
+ " total=total,",
743
+ " total_pages=total_pages,",
744
+ " status_filter=status_filter,",
745
+ " search_query=search_query,",
746
+ " date_from=date_from,",
747
+ " date_to=date_to)",
748
+ ]
749
+
750
+ tpl = tpl.replace(" {{ADMIN_ROUTE_BODY}}", "\n".join(admin_route_body_lines))
751
+
752
+ tpl = tpl.replace("{{CART_ROUTES}}", cart_routes)
753
+ tpl = tpl.replace("{{CATALOG_ROUTES}}", catalog_routes)
754
+ tpl = tpl.replace("{{REVIEW_ROUTES}}", review_routes)
755
+ tpl = tpl.replace("{{SERVICE_ROUTES}}", service_routes)
756
+
757
+ # ── {{CHECKOUT_EXTRA_FIELDS_SAVE}} (после вставки cart_routes) ────
758
+ checkout_save_lines = []
759
+ for field in config.get("checkout_fields", []):
760
+ name = field["name"]
761
+ checkout_save_lines.append(f" order.{name} = request.form.get('{name}', '').strip()")
762
+ tpl = tpl.replace(
763
+ " {{CHECKOUT_EXTRA_FIELDS_SAVE}}",
764
+ "\n".join(checkout_save_lines) if checkout_save_lines else " pass # нет доп. полей оформления"
765
+ )
766
+
767
+ # ── Демо-данные ─────────────────────────────────────────────
768
+ # ── {{CUSTOM_USER_EXTRA_SAVE}} (сохранение кастомных полей пользователя при регистрации) ─
769
+ custom_save_lines = []
770
+ for field in config.get("custom_user_fields", []):
771
+ name = field["name"]
772
+ custom_save_lines.append(f" {name}_val = request.form.get('{name}', '').strip()")
773
+ custom_save_lines.append(f" if {name}_val:")
774
+ custom_save_lines.append(f" extra_data['{name}'] = {name}_val")
775
+ tpl = tpl.replace(" {{CUSTOM_USER_EXTRA_SAVE}}", "\n".join(custom_save_lines) if custom_save_lines else "")
776
+
777
+ # ── {{DASHBOARD_ORDERS_QUERY}} и {{DASHBOARD_ORDERS_PASS}} ────────
778
+ if config.get("cart") and config.get("cart_type") == "products":
779
+ dashboard_orders_query = " user_orders = Order.query.filter_by(user_id=user.id).order_by(Order.created_at.desc()).all()"
780
+ dashboard_orders_pass = ", orders=user_orders"
781
+ else:
782
+ dashboard_orders_query = ""
783
+ dashboard_orders_pass = ""
784
+ tpl = tpl.replace(" {{DASHBOARD_ORDERS_QUERY}}", dashboard_orders_query)
785
+ tpl = tpl.replace("{{DASHBOARD_ORDERS_QUERY}}", dashboard_orders_query)
786
+ tpl = tpl.replace("{{DASHBOARD_ORDERS_PASS}}", dashboard_orders_pass)
787
+
788
+ tpl = tpl.replace(" {{DEMO_DATA}}", "")
789
+ tpl = tpl.replace("{{DEMO_DATA}}", "")
790
+
791
+ return tpl
792
+
793
+
794
+ # ─────────────────────────────────────────────
795
+ # Блоки маршрутов (строки без вложенных ''')
796
+ # ─────────────────────────────────────────────
797
+
798
+ def _cart_routes_code(config=None):
799
+ """Возвращает маршруты корзины и заказов."""
800
+ if config is None:
801
+ config = {}
802
+ lines = [
803
+ "",
804
+ "# ─────────────────────────────────────────────",
805
+ "# КОРЗИНА",
806
+ "# ─────────────────────────────────────────────",
807
+ "@app.route('/cart')",
808
+ "@login_required",
809
+ "def cart():",
810
+ " items = CartItem.query.filter_by(user_id=session['user_id']).all()",
811
+ " total = sum(i.product.price * i.quantity for i in items if i.product)",
812
+ " return render_template('cart.html', items=items, total=total)",
813
+ "",
814
+ "@app.route('/cart/add/<int:product_id>', methods=['POST'])",
815
+ "@login_required",
816
+ "def cart_add(product_id):",
817
+ " product = Product.query.get_or_404(product_id)",
818
+ " if product.stock <= 0:",
819
+ " flash('Товар закончился на складе', 'warning')",
820
+ " return redirect(request.referrer or url_for('catalog'))",
821
+ " item = CartItem.query.filter_by(user_id=session['user_id'], product_id=product_id).first()",
822
+ " if item:",
823
+ " item.quantity += 1",
824
+ " else:",
825
+ " item = CartItem(user_id=session['user_id'], product_id=product_id, quantity=1)",
826
+ " db.session.add(item)",
827
+ " product.stock -= 1",
828
+ " db.session.commit()",
829
+ " flash('Товар добавлен в корзину', 'success')",
830
+ " return redirect(request.referrer or url_for('catalog'))",
831
+ "",
832
+ "@app.route('/cart/update/<int:item_id>', methods=['POST'])",
833
+ "@login_required",
834
+ "def cart_update(item_id):",
835
+ " item = CartItem.query.get_or_404(item_id)",
836
+ " if item.user_id != session['user_id']:",
837
+ " return redirect(url_for('cart'))",
838
+ " new_qty = int(request.form.get('quantity', 1))",
839
+ " old_qty = item.quantity",
840
+ " diff = new_qty - old_qty",
841
+ " product = Product.query.get(item.product_id)",
842
+ " if new_qty < 1:",
843
+ " # Удаляем — возвращаем весь stock",
844
+ " if product:",
845
+ " product.stock += old_qty",
846
+ " db.session.delete(item)",
847
+ " else:",
848
+ " if diff > 0 and product:",
849
+ " # Добавляем ещё — проверяем наличие",
850
+ " if product.stock < diff:",
851
+ " flash('Недостаточно товара на складе', 'warning')",
852
+ " return redirect(url_for('cart'))",
853
+ " product.stock -= diff",
854
+ " elif diff < 0 and product:",
855
+ " # Уменьшаем — возвращаем на склад",
856
+ " product.stock += abs(diff)",
857
+ " item.quantity = new_qty",
858
+ " db.session.commit()",
859
+ " return redirect(url_for('cart'))",
860
+ "",
861
+ "@app.route('/cart/remove/<int:item_id>', methods=['POST'])",
862
+ "@login_required",
863
+ "def cart_remove(item_id):",
864
+ " item = CartItem.query.get_or_404(item_id)",
865
+ " if item.user_id == session['user_id']:",
866
+ " # Возвращаем товар на склад",
867
+ " product = Product.query.get(item.product_id)",
868
+ " if product:",
869
+ " product.stock += item.quantity",
870
+ " db.session.delete(item)",
871
+ " db.session.commit()",
872
+ " return redirect(url_for('cart'))",
873
+ "",
874
+ "@app.route('/checkout', methods=['GET', 'POST'])",
875
+ "@login_required",
876
+ "def checkout():",
877
+ " items = CartItem.query.filter_by(user_id=session['user_id']).all()",
878
+ " if not items:",
879
+ " flash('Корзина пуста', 'warning')",
880
+ " return redirect(url_for('cart'))",
881
+ " total = sum(i.product.price * i.quantity for i in items if i.product)",
882
+ " if request.method == 'GET':",
883
+ " return render_template('checkout.html', cart_items=items, total_price=total)",
884
+ " # POST — оформляем заказ",
885
+ " order = Order(user_id=session['user_id'], status='Новый', total_price=total)",
886
+ " {{CHECKOUT_EXTRA_FIELDS_SAVE}}",
887
+ " db.session.add(order)",
888
+ " db.session.flush()",
889
+ " for i in items:",
890
+ " oi = OrderItem(",
891
+ " order_id=order.id,",
892
+ " product_id=i.product_id,",
893
+ " product_name=i.product.name,",
894
+ " price=i.product.price,",
895
+ " quantity=i.quantity",
896
+ " )",
897
+ " db.session.add(oi)",
898
+ " db.session.delete(i)",
899
+ " db.session.commit()",
900
+ " flash('Заказ оформлен!', 'success')",
901
+ " return redirect(url_for('orders'))",
902
+ "",
903
+ "@app.route('/orders')",
904
+ "@login_required",
905
+ "def orders():",
906
+ " user_orders = Order.query.filter_by(user_id=session['user_id']).order_by(Order.created_at.desc()).all()",
907
+ " return render_template('orders.html', orders=user_orders)",
908
+ "",
909
+ "@app.route('/admin/orders')",
910
+ "@admin_required",
911
+ "def admin_orders():",
912
+ " all_orders = Order.query.order_by(Order.created_at.desc()).all()",
913
+ " order_statuses = STATUS_LIST",
914
+ " return render_template('orders.html', orders=all_orders, order_statuses=order_statuses, is_admin=True)",
915
+ "",
916
+ "@app.route('/admin/order_status/<int:order_id>', methods=['POST'])",
917
+ "@admin_required",
918
+ "def admin_order_status(order_id):",
919
+ " order = Order.query.get_or_404(order_id)",
920
+ " new_status = request.form.get('new_status', '')",
921
+ " if new_status in STATUS_LIST:",
922
+ " order.status = new_status",
923
+ " db.session.commit()",
924
+ " flash(f'Статус заказа #{order_id} изменён на «{new_status}»', 'success')",
925
+ " return redirect(request.referrer or url_for('admin_orders'))",
926
+ "",
927
+ "@app.route('/admin/order/delete/<int:order_id>', methods=['POST'])",
928
+ "@admin_required",
929
+ "def admin_delete_order(order_id):",
930
+ " order = Order.query.get_or_404(order_id)",
931
+ " # Удаляем позиции заказа",
932
+ " OrderItem.query.filter_by(order_id=order.id).delete()",
933
+ " db.session.delete(order)",
934
+ " db.session.commit()",
935
+ " flash(f'Заказ #{order_id} удалён', 'info')",
936
+ " return redirect(request.referrer or url_for('admin_orders'))",
937
+ "",
938
+ "# Управление товарами (админ)",
939
+ "@app.route('/admin/products')",
940
+ "@admin_required",
941
+ "def admin_products():",
942
+ " products = Product.query.order_by(Product.created_at.desc()).all()",
943
+ " return render_template('product_form.html', products=products, product=None)",
944
+ "",
945
+ "@app.route('/admin/product/add', methods=['GET', 'POST'])",
946
+ "@admin_required",
947
+ "def admin_product_add():",
948
+ " if request.method == 'POST':",
949
+ " p = Product(",
950
+ " name=request.form.get('name', ''),",
951
+ " price=float(request.form.get('price', 0)),",
952
+ " description=request.form.get('description', ''),",
953
+ " stock=int(request.form.get('stock', 0))",
954
+ " )",
955
+ " img = request.files.get('image')",
956
+ " if img and img.filename:",
957
+ " saved = save_uploaded_file(img, 'products')",
958
+ " if saved:",
959
+ " p.image = saved",
960
+ ]
961
+ # Сохранение кастомных полей товара при добавлении
962
+ for field in config.get("custom_product_fields", []):
963
+ name = field["name"]
964
+ lines.append(f" p.{name} = request.form.get('{name}', '').strip()")
965
+ lines += [
966
+ " db.session.add(p)",
967
+ " db.session.commit()",
968
+ " flash('Товар добавлен', 'success')",
969
+ " return redirect(url_for('admin_products'))",
970
+ " return render_template('product_form.html', products=[], product=None)",
971
+ "",
972
+ "@app.route('/admin/product/edit/<int:product_id>', methods=['GET', 'POST'])",
973
+ "@admin_required",
974
+ "def admin_product_edit(product_id):",
975
+ " p = Product.query.get_or_404(product_id)",
976
+ " if request.method == 'POST':",
977
+ " p.name = request.form.get('name', p.name)",
978
+ " p.price = float(request.form.get('price', p.price))",
979
+ " p.description = request.form.get('description', p.description)",
980
+ " p.stock = int(request.form.get('stock', p.stock))",
981
+ " img = request.files.get('image')",
982
+ " if img and img.filename:",
983
+ " saved = save_uploaded_file(img, 'products')",
984
+ " if saved:",
985
+ " p.image = saved",
986
+ ]
987
+ # Сохранение кастомных полей товара при редактировании
988
+ for field in config.get("custom_product_fields", []):
989
+ name = field["name"]
990
+ lines.append(f" p.{name} = request.form.get('{name}', '').strip()")
991
+ lines += [
992
+ " db.session.commit()",
993
+ " flash('Товар обновлён', 'success')",
994
+ " return redirect(url_for('admin_products'))",
995
+ " products = Product.query.order_by(Product.created_at.desc()).all()",
996
+ " return render_template('product_form.html', products=products, product=p)",
997
+ "",
998
+ "@app.route('/admin/product/delete/<int:product_id>', methods=['POST'])",
999
+ "@admin_required",
1000
+ "def admin_product_delete(product_id):",
1001
+ " p = Product.query.get_or_404(product_id)",
1002
+ " db.session.delete(p)",
1003
+ " db.session.commit()",
1004
+ " flash('Товар удалён', 'info')",
1005
+ " return redirect(url_for('admin_products'))",
1006
+ "",
1007
+ "@app.route('/admin/product/toggle/<int:product_id>', methods=['POST'])",
1008
+ "@admin_required",
1009
+ "def admin_product_toggle(product_id):",
1010
+ " p = Product.query.get_or_404(product_id)",
1011
+ " p.is_active = not p.is_active",
1012
+ " db.session.commit()",
1013
+ " status_text = 'активен' if p.is_active else 'снят с продажи'",
1014
+ " flash(f'Товар «{p.name}» теперь {status_text}', 'success')",
1015
+ " return redirect(url_for('admin_products'))",
1016
+ ]
1017
+ return "\n".join(lines)
1018
+
1019
+
1020
+ def _catalog_routes_code():
1021
+ """Возвращает маршруты каталога."""
1022
+ lines = [
1023
+ "",
1024
+ "# ─────────────────────────────────────────────",
1025
+ "# КАТАЛОГ",
1026
+ "# ─────────────────────────────────────────────",
1027
+ "@app.route('/catalog')",
1028
+ "def catalog():",
1029
+ " search = request.args.get('search', '').strip()",
1030
+ " min_price = request.args.get('min_price', type=float)",
1031
+ " max_price = request.args.get('max_price', type=float)",
1032
+ " q = Product.query.filter_by(is_active=True)",
1033
+ " if search:",
1034
+ " q = q.filter(Product.name.ilike(f'%{search}%'))",
1035
+ " if min_price is not None:",
1036
+ " q = q.filter(Product.price >= min_price)",
1037
+ " if max_price is not None:",
1038
+ " q = q.filter(Product.price <= max_price)",
1039
+ " products = q.order_by(Product.created_at.desc()).all()",
1040
+ " return render_template('catalog.html', products=products, search=search,",
1041
+ " min_price=min_price, max_price=max_price)",
1042
+ "",
1043
+ "@app.route('/product/<int:product_id>')",
1044
+ "def product_detail(product_id):",
1045
+ " product = Product.query.get_or_404(product_id)",
1046
+ " reviews = []",
1047
+ " can_review = False",
1048
+ " try:",
1049
+ " reviews = Review.query.filter_by(product_id=product_id).order_by(Review.created_at.desc()).all()",
1050
+ " except Exception:",
1051
+ " pass",
1052
+ " if 'user_id' in session:",
1053
+ " # Проверяем, есть ли заказ с этим товаром (любой статус кроме 'Отменён')",
1054
+ " has_order = OrderItem.query.join(Order).filter(",
1055
+ " Order.user_id == session['user_id'],",
1056
+ " Order.status != 'Отменён',",
1057
+ " OrderItem.product_id == product_id",
1058
+ " ).first()",
1059
+ " already_reviewed = Review.query.filter_by(user_id=session['user_id'], product_id=product_id).first()",
1060
+ " can_review = has_order is not None and already_reviewed is None",
1061
+ " return render_template('product.html', product=product, reviews=reviews, can_review=can_review)",
1062
+ ]
1063
+ return "\n".join(lines)
1064
+
1065
+
1066
+ def _review_routes_code():
1067
+ """Возвращает маршруты отзывов."""
1068
+ lines = [
1069
+ "",
1070
+ "# ─────────────────────────────────────────────",
1071
+ "# ОТЗЫВЫ",
1072
+ "# ─────────────────────────────────────────────",
1073
+ "@app.route('/product/<int:product_id>/review', methods=['POST'])",
1074
+ "@login_required",
1075
+ "def add_review(product_id):",
1076
+ " product = Product.query.get_or_404(product_id)",
1077
+ " # Проверяем, есть ли заказ с этим товаром (любой статус кроме 'Отменён')",
1078
+ " has_order = OrderItem.query.join(Order).filter(",
1079
+ " Order.user_id == session['user_id'],",
1080
+ " Order.status != 'Отменён',",
1081
+ " OrderItem.product_id == product_id",
1082
+ " ).first()",
1083
+ " if not has_order:",
1084
+ " flash('Отзыв можно оставить только после покупки товара', 'warning')",
1085
+ " return redirect(url_for('product_detail', product_id=product_id))",
1086
+ " # Проверяем, не оставлял ли уже отзыв",
1087
+ " existing = Review.query.filter_by(user_id=session['user_id'], product_id=product_id).first()",
1088
+ " if existing:",
1089
+ " flash('Вы уже оставили отзыв на этот товар', 'info')",
1090
+ " return redirect(url_for('product_detail', product_id=product_id))",
1091
+ " rating = int(request.form.get('rating', 5))",
1092
+ " text = request.form.get('text', '').strip()",
1093
+ " review = Review(user_id=session['user_id'], product_id=product_id,",
1094
+ " rating=max(1, min(5, rating)), text=text)",
1095
+ " db.session.add(review)",
1096
+ " db.session.commit()",
1097
+ " flash('Отзыв добавлен', 'success')",
1098
+ " return redirect(url_for('product_detail', product_id=product_id))",
1099
+ "",
1100
+ "@app.route('/admin/review/delete/<int:review_id>', methods=['POST'])",
1101
+ "@admin_required",
1102
+ "def admin_delete_review(review_id):",
1103
+ " review = Review.query.get_or_404(review_id)",
1104
+ " db.session.delete(review)",
1105
+ " db.session.commit()",
1106
+ " flash('Отзыв удалён', 'info')",
1107
+ " return redirect(request.referrer or url_for('admin'))",
1108
+ ]
1109
+ return "\n".join(lines)
1110
+
1111
+
1112
+ def _generic_review_routes_code():
1113
+ """Возвращает маршруты общих отзывов (без привязки к товарам/услугам)."""
1114
+ lines = [
1115
+ "",
1116
+ "# ─────────────────────────────────────────────",
1117
+ "# ОТЗЫВЫ (общие)",
1118
+ "# ─────────────────────────────────────────────",
1119
+ "@app.route('/reviews')",
1120
+ "def reviews_page():",
1121
+ " reviews = Review.query.order_by(Review.created_at.desc()).all()",
1122
+ " return render_template('reviews.html', reviews=reviews)",
1123
+ "",
1124
+ "@app.route('/review/add', methods=['POST'])",
1125
+ "@login_required",
1126
+ "def add_review():",
1127
+ " rating = int(request.form.get('rating', 5))",
1128
+ " text = request.form.get('text', '').strip()",
1129
+ " if not text:",
1130
+ " flash('Напишите текст отзыва', 'warning')",
1131
+ " return redirect(url_for('reviews_page'))",
1132
+ " review = Review(user_id=session['user_id'],",
1133
+ " rating=max(1, min(5, rating)), text=text)",
1134
+ " db.session.add(review)",
1135
+ " db.session.commit()",
1136
+ " flash('Отзыв добавлен!', 'success')",
1137
+ " return redirect(url_for('reviews_page'))",
1138
+ "",
1139
+ "@app.route('/admin/review/delete/<int:review_id>', methods=['POST'])",
1140
+ "@admin_required",
1141
+ "def admin_delete_review(review_id):",
1142
+ " review = Review.query.get_or_404(review_id)",
1143
+ " db.session.delete(review)",
1144
+ " db.session.commit()",
1145
+ " flash('Отзыв удалён', 'info')",
1146
+ " return redirect(request.referrer or url_for('reviews_page'))",
1147
+ "",
1148
+ "@app.route('/admin/review/reply/<int:review_id>', methods=['POST'])",
1149
+ "@admin_required",
1150
+ "def admin_reply_review(review_id):",
1151
+ " review = Review.query.get_or_404(review_id)",
1152
+ " review.admin_reply = request.form.get('admin_reply', '').strip()",
1153
+ " db.session.commit()",
1154
+ " flash('Ответ сохранён', 'success')",
1155
+ " return redirect(request.referrer or url_for('reviews_page'))",
1156
+ ]
1157
+ return "\n".join(lines)
1158
+
1159
+
1160
+ def _service_routes_code(config):
1161
+ """Возвращает маршруты модуля услуг."""
1162
+ has_image = config.get("svc_image", False)
1163
+ has_active = config.get("svc_is_active", False)
1164
+ lines = [
1165
+ "",
1166
+ "# ─────────────────────────────────────────────",
1167
+ "# УСЛУГИ",
1168
+ "# ─────────────────────────────────────────────",
1169
+ "@app.route('/services')",
1170
+ "def services():",
1171
+ " q = Service.query",
1172
+ ]
1173
+ if has_active:
1174
+ lines.append(" q = q.filter_by(is_active=True)")
1175
+ lines += [
1176
+ " all_services = q.order_by(Service.created_at.desc()).all()",
1177
+ " return render_template('services.html', services=all_services)",
1178
+ "",
1179
+ "@app.route('/service/<int:service_id>')",
1180
+ "def service_detail(service_id):",
1181
+ " svc = Service.query.get_or_404(service_id)",
1182
+ " try:",
1183
+ " reviews = Review.query.filter_by(service_id=service_id).order_by(Review.created_at.desc()).all()",
1184
+ " except Exception:",
1185
+ " reviews = []",
1186
+ " return render_template('service_detail.html', service=svc, reviews=reviews)",
1187
+ "",
1188
+ "@app.route('/service/<int:service_id>/request', methods=['POST'])",
1189
+ "@login_required",
1190
+ "def request_service(service_id):",
1191
+ " svc = Service.query.get_or_404(service_id)",
1192
+ " # Админ не может подавать заявки",
1193
+ " user = User.query.get(session['user_id'])",
1194
+ " if user and user.role == 'admin':",
1195
+ " flash('Администратор не может подавать заявки', 'warning')",
1196
+ " return redirect(url_for('service_detail', service_id=service_id))",
1197
+ " req = Request(",
1198
+ " user_id=session['user_id'],",
1199
+ " status=STATUS_LIST[0],",
1200
+ " service_id=service_id,",
1201
+ " )",
1202
+ ]
1203
+ # Добавляем сохранение кастомных полей заявки
1204
+ for field in config.get("custom_request_fields", []):
1205
+ name = field["name"]
1206
+ lines.append(f" req.{name} = request.form.get('{name}', '').strip()")
1207
+ lines += [
1208
+ " db.session.add(req)",
1209
+ " db.session.commit()",
1210
+ " flash(f'Заявка на услугу «{svc.name}» успешно подана!', 'success')",
1211
+ " return redirect(url_for('dashboard'))",
1212
+ "",
1213
+ "# ── Управление услугами (только админ) ──",
1214
+ "@app.route('/admin/services')",
1215
+ "@admin_required",
1216
+ "def admin_services():",
1217
+ " all_services = Service.query.order_by(Service.created_at.desc()).all()",
1218
+ " return render_template('service_form.html', services=all_services, service=None)",
1219
+ "",
1220
+ "@app.route('/admin/service/add', methods=['GET', 'POST'])",
1221
+ "@admin_required",
1222
+ "def admin_service_add():",
1223
+ " if request.method == 'POST':",
1224
+ " svc = Service(name=request.form.get('name', '').strip())",
1225
+ ]
1226
+ if config.get("svc_description"):
1227
+ lines.append(" svc.description = request.form.get('description', '')")
1228
+ if config.get("svc_price"):
1229
+ lines.append(" try: svc.price = float(request.form.get('price', 0))")
1230
+ lines.append(" except: svc.price = 0.0")
1231
+ if config.get("svc_duration"):
1232
+ lines.append(" svc.duration = request.form.get('duration', '')")
1233
+ if config.get("svc_category"):
1234
+ lines.append(" svc.category = request.form.get('category', '')")
1235
+ if config.get("svc_max_clients"):
1236
+ lines.append(" try: svc.max_clients = int(request.form.get('max_clients', 0))")
1237
+ lines.append(" except: svc.max_clients = None")
1238
+ if config.get("svc_location"):
1239
+ lines.append(" svc.location = request.form.get('location', '')")
1240
+ if config.get("svc_requirements"):
1241
+ lines.append(" svc.requirements = request.form.get('requirements', '')")
1242
+ if has_active:
1243
+ lines.append(" svc.is_active = request.form.get('is_active') == 'on'")
1244
+ if has_image:
1245
+ lines += [
1246
+ " img = request.files.get('image')",
1247
+ " if img and img.filename:",
1248
+ " saved = save_uploaded_file(img, 'services')",
1249
+ " if saved:",
1250
+ " svc.image = saved",
1251
+ ]
1252
+ lines += [
1253
+ " db.session.add(svc)",
1254
+ " db.session.commit()",
1255
+ " flash('Услуга добавлена', 'success')",
1256
+ " return redirect(url_for('admin_services'))",
1257
+ " return render_template('service_form.html', services=[], service=None)",
1258
+ "",
1259
+ "@app.route('/admin/service/edit/<int:service_id>', methods=['GET', 'POST'])",
1260
+ "@admin_required",
1261
+ "def admin_service_edit(service_id):",
1262
+ " svc = Service.query.get_or_404(service_id)",
1263
+ " if request.method == 'POST':",
1264
+ " svc.name = request.form.get('name', svc.name).strip()",
1265
+ ]
1266
+ if config.get("svc_description"):
1267
+ lines.append(" svc.description = request.form.get('description', svc.description)")
1268
+ if config.get("svc_price"):
1269
+ lines.append(" try: svc.price = float(request.form.get('price', svc.price))")
1270
+ lines.append(" except: pass")
1271
+ if config.get("svc_duration"):
1272
+ lines.append(" svc.duration = request.form.get('duration', svc.duration)")
1273
+ if config.get("svc_category"):
1274
+ lines.append(" svc.category = request.form.get('category', svc.category)")
1275
+ if config.get("svc_max_clients"):
1276
+ lines.append(" try: svc.max_clients = int(request.form.get('max_clients', svc.max_clients))")
1277
+ lines.append(" except: pass")
1278
+ if config.get("svc_location"):
1279
+ lines.append(" svc.location = request.form.get('location', svc.location)")
1280
+ if config.get("svc_requirements"):
1281
+ lines.append(" svc.requirements = request.form.get('requirements', svc.requirements)")
1282
+ if has_active:
1283
+ lines.append(" svc.is_active = request.form.get('is_active') == 'on'")
1284
+ if has_image:
1285
+ lines += [
1286
+ " img = request.files.get('image')",
1287
+ " if img and img.filename:",
1288
+ " saved = save_uploaded_file(img, 'services')",
1289
+ " if saved:",
1290
+ " svc.image = saved",
1291
+ ]
1292
+ lines += [
1293
+ " db.session.commit()",
1294
+ " flash('Услуга обновлена', 'success')",
1295
+ " return redirect(url_for('admin_services'))",
1296
+ " all_services = Service.query.order_by(Service.created_at.desc()).all()",
1297
+ " return render_template('service_form.html', services=all_services, service=svc)",
1298
+ "",
1299
+ "@app.route('/admin/service/delete/<int:service_id>', methods=['POST'])",
1300
+ "@admin_required",
1301
+ "def admin_service_delete(service_id):",
1302
+ " svc = Service.query.get_or_404(service_id)",
1303
+ " db.session.delete(svc)",
1304
+ " db.session.commit()",
1305
+ " flash('Услуга удалена', 'info')",
1306
+ " return redirect(url_for('admin_services'))",
1307
+ ]
1308
+ # Маршруты отзывов на услуги — только если reviews включены
1309
+ if config.get("reviews"):
1310
+ lines += [
1311
+ "",
1312
+ "# ── Отзывы на услуги ──",
1313
+ "@app.route('/service/<int:service_id>/review', methods=['POST'])",
1314
+ "@login_required",
1315
+ "def add_service_review(service_id):",
1316
+ " svc = Service.query.get_or_404(service_id)",
1317
+ " rating = int(request.form.get('rating', 5))",
1318
+ " text = request.form.get('text', '').strip()",
1319
+ " if not text:",
1320
+ " flash('Напишите текст отзыва', 'warning')",
1321
+ " return redirect(url_for('service_detail', service_id=service_id))",
1322
+ " review = Review(user_id=session['user_id'], service_id=service_id,",
1323
+ " rating=max(1, min(5, rating)), text=text)",
1324
+ " db.session.add(review)",
1325
+ " db.session.commit()",
1326
+ " flash('Отзыв добавлен!', 'success')",
1327
+ " return redirect(url_for('service_detail', service_id=service_id))",
1328
+ "",
1329
+ "@app.route('/admin/review/delete/<int:review_id>', methods=['POST'])",
1330
+ "@admin_required",
1331
+ "def admin_delete_review(review_id):",
1332
+ " review = Review.query.get_or_404(review_id)",
1333
+ " service_id = review.service_id",
1334
+ " db.session.delete(review)",
1335
+ " db.session.commit()",
1336
+ " flash('Отзыв удалён', 'info')",
1337
+ " if service_id:",
1338
+ " return redirect(url_for('service_detail', service_id=service_id))",
1339
+ " return redirect(url_for('admin'))",
1340
+ "",
1341
+ "@app.route('/admin/review/reply/<int:review_id>', methods=['POST'])",
1342
+ "@admin_required",
1343
+ "def admin_reply_review(review_id):",
1344
+ " review = Review.query.get_or_404(review_id)",
1345
+ " review.admin_reply = request.form.get('admin_reply', '').strip()",
1346
+ " db.session.commit()",
1347
+ " flash('Ответ сохранён', 'success')",
1348
+ " if review.service_id:",
1349
+ " return redirect(url_for('service_detail', service_id=review.service_id))",
1350
+ " return redirect(url_for('admin'))",
1351
+ ]
1352
+ return "\n".join(lines)
1353
+
1354
+
1355
+ def _replace_html_placeholders(html, config):
1356
+ """Заменяет плейсхолдеры в HTML-шаблонах на основе конфига."""
1357
+
1358
+ # ── Поля авторизации (login vs email) ─────────────────────────────
1359
+ if config.get("auth_by_email"):
1360
+ html = html.replace("{{LOGIN_FIELD_ID}}", "email")
1361
+ html = html.replace("{{LOGIN_FIELD_LABEL}}", "Email")
1362
+ html = html.replace("{{LOGIN_FIELD_TYPE}}", "email")
1363
+ html = html.replace("{{LOGIN_FIELD_PLACEHOLDER}}", "Введите email")
1364
+ html = html.replace("{{LOGIN_FIELD_AUTOCOMPLETE}}", "email")
1365
+ else:
1366
+ html = html.replace("{{LOGIN_FIELD_ID}}", "login")
1367
+ html = html.replace("{{LOGIN_FIELD_LABEL}}", "Логин")
1368
+ html = html.replace("{{LOGIN_FIELD_TYPE}}", "text")
1369
+ html = html.replace("{{LOGIN_FIELD_PLACEHOLDER}}", "Введите логин")
1370
+ html = html.replace("{{LOGIN_FIELD_AUTOCOMPLETE}}", "username")
1371
+
1372
+ # ── {{REGISTER_LOGIN_FIELD}} ──────────────────────────────────────
1373
+ if config.get("auth_by_email"):
1374
+ # При авторизации по email — поле логина не нужно, email будет в REGISTER_EXTRA_FIELDS
1375
+ html = html.replace("{{REGISTER_LOGIN_FIELD}}", "<!-- авторизация по email, поле логина не используется -->")
1376
+ else:
1377
+ login_field_html = (
1378
+ ' <!-- Логин -->\n'
1379
+ ' <div class="mb-3">\n'
1380
+ ' <label for="login" class="form-label fw-semibold">Логин <span class="text-danger">*</span></label>\n'
1381
+ ' <input type="text" class="form-control" id="login" name="login"\n'
1382
+ ' placeholder="Минимум 6 символов, только латиница и цифры"\n'
1383
+ ' required autocomplete="username">\n'
1384
+ ' <div class="invalid-feedback" id="loginError"></div>\n'
1385
+ ' <div class="valid-feedback">Логин доступен ✓</div>\n'
1386
+ ' </div>'
1387
+ )
1388
+ html = html.replace("{{REGISTER_LOGIN_FIELD}}", login_field_html)
1389
+
1390
+ # ── {{REGISTER_LOGIN_CHECK_SCRIPT}} ───────────────────────────────
1391
+ if config.get("auth_by_email"):
1392
+ email_check_script = (
1393
+ "// ─── AJAX-проверка уникальности email ───────────────────────────\n"
1394
+ "let emailCheckTimer = null;\n"
1395
+ "const emailInput = document.getElementById('email');\n"
1396
+ "if (emailInput) {\n"
1397
+ " emailInput.addEventListener('input', function() {\n"
1398
+ " clearTimeout(emailCheckTimer);\n"
1399
+ " const val = this.value.trim();\n"
1400
+ " const errEl = document.getElementById('emailError');\n"
1401
+ " const input = this;\n"
1402
+ " if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(val)) {\n"
1403
+ " input.classList.add('is-invalid');\n"
1404
+ " input.classList.remove('is-valid');\n"
1405
+ " if (errEl) errEl.textContent = 'Введите корректный email';\n"
1406
+ " return;\n"
1407
+ " }\n"
1408
+ " emailCheckTimer = setTimeout(function() {\n"
1409
+ " fetch('/check_email?email=' + encodeURIComponent(val))\n"
1410
+ " .then(r => r.json())\n"
1411
+ " .then(data => {\n"
1412
+ " if (data.exists) {\n"
1413
+ " input.classList.add('is-invalid');\n"
1414
+ " input.classList.remove('is-valid');\n"
1415
+ " if (errEl) errEl.textContent = 'Этот email уже зарегистрирован';\n"
1416
+ " } else {\n"
1417
+ " input.classList.remove('is-invalid');\n"
1418
+ " input.classList.add('is-valid');\n"
1419
+ " if (errEl) errEl.textContent = '';\n"
1420
+ " }\n"
1421
+ " });\n"
1422
+ " }, 400);\n"
1423
+ " });\n"
1424
+ "}"
1425
+ )
1426
+ html = html.replace("{{REGISTER_LOGIN_CHECK_SCRIPT}}", email_check_script)
1427
+ else:
1428
+ login_check_script = (
1429
+ "// ─── AJAX-проверка уникальности логина ───────────────────────────\n"
1430
+ "let loginCheckTimer = null;\n"
1431
+ "document.getElementById('login').addEventListener('input', function() {\n"
1432
+ " clearTimeout(loginCheckTimer);\n"
1433
+ " const val = this.value.trim();\n"
1434
+ " const errEl = document.getElementById('loginError');\n"
1435
+ " const input = this;\n"
1436
+ " if (!/^[a-zA-Z0-9]{6,}$/.test(val)) {\n"
1437
+ " input.classList.add('is-invalid');\n"
1438
+ " input.classList.remove('is-valid');\n"
1439
+ " errEl.textContent = 'Только латиница и цифры, минимум 6 символов';\n"
1440
+ " return;\n"
1441
+ " }\n"
1442
+ " loginCheckTimer = setTimeout(function() {\n"
1443
+ " fetch('/check_login?login=' + encodeURIComponent(val))\n"
1444
+ " .then(r => r.json())\n"
1445
+ " .then(data => {\n"
1446
+ " if (data.exists) {\n"
1447
+ " input.classList.add('is-invalid');\n"
1448
+ " input.classList.remove('is-valid');\n"
1449
+ " errEl.textContent = 'Этот логин уже занят';\n"
1450
+ " } else {\n"
1451
+ " input.classList.remove('is-invalid');\n"
1452
+ " input.classList.add('is-valid');\n"
1453
+ " errEl.textContent = '';\n"
1454
+ " }\n"
1455
+ " });\n"
1456
+ " }, 400);\n"
1457
+ "});"
1458
+ )
1459
+ html = html.replace("{{REGISTER_LOGIN_CHECK_SCRIPT}}", login_check_script)
1460
+
1461
+ # ── {{REGISTER_EXTRA_FIELDS}} ─────────────────────────────────────
1462
+ reg_fields = []
1463
+ if config.get("user_full_name"):
1464
+ reg_fields.append(
1465
+ ' <!-- ФИО -->\n'
1466
+ ' <div class="mb-3">\n'
1467
+ ' <label for="full_name" class="form-label fw-semibold">ФИО <span class="text-danger">*</span></label>\n'
1468
+ ' <input type="text" class="form-control" id="full_name" name="full_name"\n'
1469
+ ' placeholder="Иванов Иван Иванович" required>\n'
1470
+ ' <div class="invalid-feedback" id="full_nameError"></div>\n'
1471
+ ' </div>'
1472
+ )
1473
+ if config.get("user_email"):
1474
+ reg_fields.append(
1475
+ ' <!-- Email -->\n'
1476
+ ' <div class="mb-3">\n'
1477
+ ' <label for="email" class="form-label fw-semibold">Email <span class="text-danger">*</span></label>\n'
1478
+ ' <input type="email" class="form-control" id="email" name="email"\n'
1479
+ ' placeholder="example@mail.ru" required>\n'
1480
+ ' <div class="invalid-feedback" id="emailError"></div>\n'
1481
+ ' </div>'
1482
+ )
1483
+ if config.get("user_phone"):
1484
+ reg_fields.append(
1485
+ ' <!-- Телефон -->\n'
1486
+ ' <div class="mb-3">\n'
1487
+ ' <label for="phone" class="form-label fw-semibold">Телефон <span class="text-danger">*</span></label>\n'
1488
+ ' <input type="tel" class="form-control" id="phone" name="phone"\n'
1489
+ ' placeholder="+7 (999) 999-99-99" required>\n'
1490
+ ' <div class="invalid-feedback" id="phoneError"></div>\n'
1491
+ ' </div>'
1492
+ )
1493
+ if config.get("user_birth_date"):
1494
+ reg_fields.append(
1495
+ ' <!-- Дата рождения -->\n'
1496
+ ' <div class="mb-3">\n'
1497
+ ' <label for="birth_date" class="form-label fw-semibold">Дата рождения</label>\n'
1498
+ ' <input type="date" class="form-control" id="birth_date" name="birth_date">\n'
1499
+ ' </div>'
1500
+ )
1501
+ if config.get("user_address"):
1502
+ reg_fields.append(
1503
+ ' <!-- Адрес -->\n'
1504
+ ' <div class="mb-3">\n'
1505
+ ' <label for="address" class="form-label fw-semibold">Адрес</label>\n'
1506
+ ' <input type="text" class="form-control" id="address" name="address"\n'
1507
+ ' placeholder="г. Москва, ул. Примерная, д. 1">\n'
1508
+ ' </div>'
1509
+ )
1510
+ if config.get("user_gender"):
1511
+ reg_fields.append(
1512
+ ' <!-- Пол -->\n'
1513
+ ' <div class="mb-3">\n'
1514
+ ' <label for="gender" class="form-label fw-semibold">Пол</label>\n'
1515
+ ' <select class="form-select" id="gender" name="gender">\n'
1516
+ ' <option value="">— Не указан —</option>\n'
1517
+ ' <option value="male">Мужской</option>\n'
1518
+ ' <option value="female">Женский</option>\n'
1519
+ ' </select>\n'
1520
+ ' </div>'
1521
+ )
1522
+ if config.get("user_city"):
1523
+ reg_fields.append(
1524
+ ' <!-- Город -->\n'
1525
+ ' <div class="mb-3">\n'
1526
+ ' <label for="city" class="form-label fw-semibold">Город</label>\n'
1527
+ ' <input type="text" class="form-control" id="city" name="city"\n'
1528
+ ' placeholder="Москва">\n'
1529
+ ' </div>'
1530
+ )
1531
+ if config.get("user_workplace"):
1532
+ reg_fields.append(
1533
+ ' <!-- Место работы/учёбы -->\n'
1534
+ ' <div class="mb-3">\n'
1535
+ ' <label for="workplace" class="form-label fw-semibold">Место работы/учёбы</label>\n'
1536
+ ' <input type="text" class="form-control" id="workplace" name="workplace"\n'
1537
+ ' placeholder="ООО «Компания» / МГУ">\n'
1538
+ ' </div>'
1539
+ )
1540
+ if config.get("user_passport"):
1541
+ reg_fields.append(
1542
+ ' <!-- Паспортные данные -->\n'
1543
+ ' <div class="mb-3">\n'
1544
+ ' <label for="passport" class="form-label fw-semibold">Паспортные данные (серия и номер)</label>\n'
1545
+ ' <input type="text" class="form-control" id="passport" name="passport"\n'
1546
+ ' placeholder="1234 567890">\n'
1547
+ ' </div>'
1548
+ )
1549
+ if config.get("user_inn"):
1550
+ reg_fields.append(
1551
+ ' <!-- ИНН -->\n'
1552
+ ' <div class="mb-3">\n'
1553
+ ' <label for="inn" class="form-label fw-semibold">ИНН</label>\n'
1554
+ ' <input type="text" class="form-control" id="inn" name="inn"\n'
1555
+ ' placeholder="123456789012" maxlength="12">\n'
1556
+ ' </div>'
1557
+ )
1558
+ if config.get("user_snils"):
1559
+ reg_fields.append(
1560
+ ' <!-- СНИЛС -->\n'
1561
+ ' <div class="mb-3">\n'
1562
+ ' <label for="snils" class="form-label fw-semibold">СНИЛС</label>\n'
1563
+ ' <input type="text" class="form-control" id="snils" name="snils"\n'
1564
+ ' placeholder="123-456-789 00">\n'
1565
+ ' </div>'
1566
+ )
1567
+ if config.get("user_education"):
1568
+ reg_fields.append(
1569
+ ' <!-- Образование -->\n'
1570
+ ' <div class="mb-3">\n'
1571
+ ' <label for="education" class="form-label fw-semibold">Образование</label>\n'
1572
+ ' <input type="text" class="form-control" id="education" name="education"\n'
1573
+ ' placeholder="Высшее, МГУ, 2020">\n'
1574
+ ' </div>'
1575
+ )
1576
+ if config.get("user_position"):
1577
+ reg_fields.append(
1578
+ ' <!-- Должность -->\n'
1579
+ ' <div class="mb-3">\n'
1580
+ ' <label for="position" class="form-label fw-semibold">Должность</label>\n'
1581
+ ' <input type="text" class="form-control" id="position" name="position"\n'
1582
+ ' placeholder="Менеджер">\n'
1583
+ ' </div>'
1584
+ )
1585
+ if config.get("user_telegram"):
1586
+ reg_fields.append(
1587
+ ' <!-- Telegram -->\n'
1588
+ ' <div class="mb-3">\n'
1589
+ ' <label for="telegram" class="form-label fw-semibold">Telegram</label>\n'
1590
+ ' <input type="text" class="form-control" id="telegram" name="telegram"\n'
1591
+ ' placeholder="@username">\n'
1592
+ ' </div>'
1593
+ )
1594
+ html = html.replace(
1595
+ "{{REGISTER_EXTRA_FIELDS}}",
1596
+ "\n".join(reg_fields) if reg_fields else "<!-- доп. поля не настроены -->"
1597
+ )
1598
+
1599
+ # ── {{PROFILE_EXTRA_FIELDS}} ──────────────────────────────────────
1600
+ profile_fields = []
1601
+ if config.get("user_full_name"):
1602
+ profile_fields.append(
1603
+ ' <div class="mb-3">\n'
1604
+ ' <label class="form-label fw-semibold">ФИО</label>\n'
1605
+ ' <input type="text" class="form-control" name="full_name"\n'
1606
+ ' value="{{ user.full_name or \'\' }}" placeholder="Иванов Иван Иванович">\n'
1607
+ ' </div>'
1608
+ )
1609
+ if config.get("user_email"):
1610
+ profile_fields.append(
1611
+ ' <div class="mb-3">\n'
1612
+ ' <label class="form-label fw-semibold">Email</label>\n'
1613
+ ' <input type="email" class="form-control" name="email"\n'
1614
+ ' value="{{ user.email or \'\' }}" placeholder="example@mail.ru">\n'
1615
+ ' </div>'
1616
+ )
1617
+ if config.get("user_phone"):
1618
+ profile_fields.append(
1619
+ ' <div class="mb-3">\n'
1620
+ ' <label class="form-label fw-semibold">Телефон</label>\n'
1621
+ ' <input type="tel" class="form-control" id="profilePhone" name="phone"\n'
1622
+ ' value="{{ user.phone or \'\' }}" placeholder="+7 (999) 999-99-99">\n'
1623
+ ' </div>'
1624
+ )
1625
+ if config.get("user_birth_date"):
1626
+ profile_fields.append(
1627
+ ' <div class="mb-3">\n'
1628
+ ' <label class="form-label fw-semibold">Дата рождения</label>\n'
1629
+ ' <input type="date" class="form-control" name="birth_date"\n'
1630
+ ' value="{{ user.birth_date or \'\' }}">\n'
1631
+ ' </div>'
1632
+ )
1633
+ if config.get("user_address"):
1634
+ profile_fields.append(
1635
+ ' <div class="mb-3">\n'
1636
+ ' <label class="form-label fw-semibold">Адрес</label>\n'
1637
+ ' <input type="text" class="form-control" name="address"\n'
1638
+ ' value="{{ user.address or \'\' }}" placeholder="г. Москва, ул. Примерная, д. 1">\n'
1639
+ ' </div>'
1640
+ )
1641
+ if config.get("user_gender"):
1642
+ profile_fields.append(
1643
+ ' <div class="mb-3">\n'
1644
+ ' <label class="form-label fw-semibold">Пол</label>\n'
1645
+ ' <select class="form-select" name="gender">\n'
1646
+ ' <option value="">— Не указан —</option>\n'
1647
+ ' <option value="male" {% if user.gender == \'male\' %}selected{% endif %}>Мужской</option>\n'
1648
+ ' <option value="female" {% if user.gender == \'female\' %}selected{% endif %}>Женский</option>\n'
1649
+ ' </select>\n'
1650
+ ' </div>'
1651
+ )
1652
+ if config.get("user_city"):
1653
+ profile_fields.append(
1654
+ ' <div class="mb-3">\n'
1655
+ ' <label class="form-label fw-semibold">Город</label>\n'
1656
+ ' <input type="text" class="form-control" name="city"\n'
1657
+ ' value="{{ user.city or \'\' }}" placeholder="Москва">\n'
1658
+ ' </div>'
1659
+ )
1660
+ if config.get("user_workplace"):
1661
+ profile_fields.append(
1662
+ ' <div class="mb-3">\n'
1663
+ ' <label class="form-label fw-semibold">Место работы/учёбы</label>\n'
1664
+ ' <input type="text" class="form-control" name="workplace"\n'
1665
+ ' value="{{ user.workplace or \'\' }}" placeholder="ООО «Компания» / МГУ">\n'
1666
+ ' </div>'
1667
+ )
1668
+ if config.get("user_passport"):
1669
+ profile_fields.append(
1670
+ ' <div class="mb-3">\n'
1671
+ ' <label class="form-label fw-semibold">Паспортные данные (серия и номер)</label>\n'
1672
+ ' <input type="text" class="form-control" name="passport"\n'
1673
+ ' value="{{ user.passport or \'\' }}" placeholder="1234 567890">\n'
1674
+ ' </div>'
1675
+ )
1676
+ if config.get("user_inn"):
1677
+ profile_fields.append(
1678
+ ' <div class="mb-3">\n'
1679
+ ' <label class="form-label fw-semibold">ИНН</label>\n'
1680
+ ' <input type="text" class="form-control" name="inn"\n'
1681
+ ' value="{{ user.inn or \'\' }}" placeholder="123456789012" maxlength="12">\n'
1682
+ ' </div>'
1683
+ )
1684
+ if config.get("user_snils"):
1685
+ profile_fields.append(
1686
+ ' <div class="mb-3">\n'
1687
+ ' <label class="form-label fw-semibold">СНИЛС</label>\n'
1688
+ ' <input type="text" class="form-control" name="snils"\n'
1689
+ ' value="{{ user.snils or \'\' }}" placeholder="123-456-789 00">\n'
1690
+ ' </div>'
1691
+ )
1692
+ if config.get("user_education"):
1693
+ profile_fields.append(
1694
+ ' <div class="mb-3">\n'
1695
+ ' <label class="form-label fw-semibold">Образование</label>\n'
1696
+ ' <input type="text" class="form-control" name="education"\n'
1697
+ ' value="{{ user.education or \'\' }}" placeholder="Высшее, МГУ, 2020">\n'
1698
+ ' </div>'
1699
+ )
1700
+ if config.get("user_position"):
1701
+ profile_fields.append(
1702
+ ' <div class="mb-3">\n'
1703
+ ' <label class="form-label fw-semibold">Должность</label>\n'
1704
+ ' <input type="text" class="form-control" name="position"\n'
1705
+ ' value="{{ user.position or \'\' }}" placeholder="Менеджер">\n'
1706
+ ' </div>'
1707
+ )
1708
+ if config.get("user_telegram"):
1709
+ profile_fields.append(
1710
+ ' <div class="mb-3">\n'
1711
+ ' <label class="form-label fw-semibold">Telegram</label>\n'
1712
+ ' <input type="text" class="form-control" name="telegram"\n'
1713
+ ' value="{{ user.telegram or \'\' }}" placeholder="@username">\n'
1714
+ ' </div>'
1715
+ )
1716
+ html = html.replace(
1717
+ "{{PROFILE_EXTRA_FIELDS}}",
1718
+ "\n".join(profile_fields) if profile_fields else "<!-- доп. поля не настроены -->"
1719
+ )
1720
+
1721
+ # ── {{PHONE_MASK_SCRIPT}} ─────────────────────────────────────────
1722
+ if config.get("user_phone"):
1723
+ phone_mask = (
1724
+ "const phoneInput = document.getElementById('phone');\n"
1725
+ "if (phoneInput) { Inputmask('+7 (999) 999-99-99').mask(phoneInput); }\n"
1726
+ "const profilePhone = document.getElementById('profilePhone');\n"
1727
+ "if (profilePhone) { Inputmask('+7 (999) 999-99-99').mask(profilePhone); }"
1728
+ )
1729
+ else:
1730
+ phone_mask = "// маска телефона не используется"
1731
+ html = html.replace("{{PHONE_MASK_SCRIPT}}", phone_mask)
1732
+
1733
+ # ── {{SERVICES_NAV_ITEM}} ─────────────────────────────────────────
1734
+ if config.get("services"):
1735
+ services_nav = (
1736
+ '<li class="nav-item">\n'
1737
+ ' <a class="nav-link" href="{{ url_for(\'services\') }}">'
1738
+ '\xf0\x9f\x93\x8b Услуги</a>\n'
1739
+ ' </li>'
1740
+ )
1741
+ else:
1742
+ services_nav = "<!-- услуги не используются -->"
1743
+ html = html.replace("{{SERVICES_NAV_ITEM}}", services_nav)
1744
+
1745
+ # ── {{CART_NAV_ITEM}} ─────────────────────────────────────────────
1746
+ if config.get("cart") and config.get("cart_type") == "products":
1747
+ cart_nav = (
1748
+ '<li class="nav-item">\n'
1749
+ ' <a class="nav-link" href="{{ url_for(\'cart\') }}">'
1750
+ '\xf0\x9f\x9b\x92 Корзина</a>\n'
1751
+ ' </li>\n'
1752
+ ' <li class="nav-item">\n'
1753
+ ' <a class="nav-link" href="{{ url_for(\'catalog\') }}">'
1754
+ '\xf0\x9f\x93\xa6 Каталог</a>\n'
1755
+ ' </li>'
1756
+ )
1757
+ else:
1758
+ cart_nav = "<!-- корзина не используется -->"
1759
+ html = html.replace("{{CART_NAV_ITEM}}", cart_nav)
1760
+
1761
+ # ── {{ADMIN_SERVICE_BUTTONS}} ─────────────────────────────────────
1762
+ if config.get("services"):
1763
+ admin_svc_buttons = (
1764
+ '<a href="{{ url_for(\'admin_services\') }}" class="btn btn-outline-success btn-sm">\n'
1765
+ ' \xf0\x9f\x9b\xa0 Услуги\n'
1766
+ ' </a>\n'
1767
+ ' <button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#addServiceModal">\n'
1768
+ ' \xe2\x9e\x95 Добавить услугу\n'
1769
+ ' </button>'
1770
+ )
1771
+ else:
1772
+ admin_svc_buttons = "<!-- услуги не используются -->"
1773
+ html = html.replace("{{ADMIN_SERVICE_BUTTONS}}", admin_svc_buttons)
1774
+
1775
+ # ── {{ADMIN_PRODUCT_BUTTONS}} ─────────────────────────────────────
1776
+ if config.get("cart") and config.get("cart_type") == "products":
1777
+ admin_product_buttons = (
1778
+ '<a href="{{ url_for(\'admin_products\') }}" class="btn btn-outline-info btn-sm">\n'
1779
+ ' 📦 Товары\n'
1780
+ ' </a>\n'
1781
+ ' <a href="{{ url_for(\'admin_orders\') }}" class="btn btn-outline-warning btn-sm">\n'
1782
+ ' 🛒 Покупки\n'
1783
+ ' </a>\n'
1784
+ ' <a href="{{ url_for(\'admin_product_add\') }}" class="btn btn-info btn-sm">\n'
1785
+ ' ➕ Создать товар\n'
1786
+ ' </a>'
1787
+ )
1788
+ else:
1789
+ admin_product_buttons = "<!-- товары не используются -->"
1790
+ html = html.replace("{{ADMIN_PRODUCT_BUTTONS}}", admin_product_buttons)
1791
+
1792
+ # ── {{DASHBOARD_ACTION_BUTTONS}} ──────────────────────────────────
1793
+ if config.get("cart") and config.get("cart_type") == "products":
1794
+ dashboard_action_buttons = (
1795
+ '<a href="{{ url_for(\'catalog\') }}" class="btn btn-primary btn-sm">\n'
1796
+ ' 📦 Каталог\n'
1797
+ ' </a>\n'
1798
+ ' <a href="{{ url_for(\'orders\') }}" class="btn btn-outline-info btn-sm">\n'
1799
+ ' 📋 Мои покупки\n'
1800
+ ' </a>'
1801
+ )
1802
+ else:
1803
+ dashboard_action_buttons = (
1804
+ '<a href="{{ url_for(\'new_request\') }}" class="btn btn-primary btn-sm">\n'
1805
+ ' ➕ Новая заявка\n'
1806
+ ' </a>'
1807
+ )
1808
+ html = html.replace("{{DASHBOARD_ACTION_BUTTONS}}", dashboard_action_buttons)
1809
+
1810
+ # ── {{DASHBOARD_MAIN_CONTENT}} ────────────────────────────────────
1811
+ if config.get("cart") and config.get("cart_type") == "products":
1812
+ # Генерируем строки для отображения checkout полей в модальном окне
1813
+ order_detail_fields = ""
1814
+ for field in config.get("checkout_fields", []):
1815
+ name = field["name"]
1816
+ label = field["label"]
1817
+ order_detail_fields += (
1818
+ f' <dt class="col-sm-5">{label}:</dt>\n'
1819
+ f' <dd class="col-sm-7">{{{{ order.{name} or "—" }}}}</dd>\n'
1820
+ )
1821
+
1822
+ dashboard_main_content = (
1823
+ ' <!-- История покупок -->\n'
1824
+ ' <div class="col-12">\n'
1825
+ ' <div class="card border-0 shadow-sm">\n'
1826
+ ' <div class="card-header bg-info text-white d-flex justify-content-between align-items-center">\n'
1827
+ ' <h5 class="mb-0">🛒 Мои покупки</h5>\n'
1828
+ ' <span class="badge bg-white text-dark">{{ orders|length }}</span>\n'
1829
+ ' </div>\n'
1830
+ ' <div class="card-body">\n'
1831
+ ' {% if orders %}\n'
1832
+ ' <div class="table-responsive">\n'
1833
+ ' <table class="table table-hover align-middle">\n'
1834
+ ' <thead class="table-light">\n'
1835
+ ' <tr>\n'
1836
+ ' <th>#</th>\n'
1837
+ ' <th>Дата</th>\n'
1838
+ ' <th>Сумма</th>\n'
1839
+ ' <th>Статус</th>\n'
1840
+ ' <th>Товары</th>\n'
1841
+ ' <th>Детали</th>\n'
1842
+ ' </tr>\n'
1843
+ ' </thead>\n'
1844
+ ' <tbody>\n'
1845
+ ' {% for order in orders %}\n'
1846
+ ' <tr>\n'
1847
+ ' <td class="text-muted">{{ order.id }}</td>\n'
1848
+ ' <td>{{ order.created_at.strftime(\'%d.%m.%Y\') }}<br>\n'
1849
+ ' <small class="text-muted">{{ order.created_at.strftime(\'%H:%M\') }}</small>\n'
1850
+ ' </td>\n'
1851
+ ' <td class="fw-bold">{{ "%.2f"|format(order.total_price) }} ₽</td>\n'
1852
+ ' <td>\n'
1853
+ ' {% set order_colors = {\'Новый\': \'warning\', \'Оплачен\': \'info\', \'Отправлен\': \'primary\', \'Доставлен\': \'success\', \'Отменён\': \'danger\'} %}\n'
1854
+ ' <span class="badge bg-{{ order_colors.get(order.status, \'secondary\') }}">\n'
1855
+ ' {{ order.status }}\n'
1856
+ ' </span>\n'
1857
+ ' </td>\n'
1858
+ ' <td>\n'
1859
+ ' {% for item in order.items %}\n'
1860
+ ' <small>{{ item.product_name }} × {{ item.quantity }}</small><br>\n'
1861
+ ' {% endfor %}\n'
1862
+ ' </td>\n'
1863
+ ' <td>\n'
1864
+ ' <button type="button" class="btn btn-sm btn-outline-secondary"\n'
1865
+ ' data-bs-toggle="modal" data-bs-target="#orderModal{{ order.id }}">\n'
1866
+ ' 👁 Подробнее\n'
1867
+ ' </button>\n'
1868
+ ' </td>\n'
1869
+ ' </tr>\n'
1870
+ ' {% endfor %}\n'
1871
+ ' </tbody>\n'
1872
+ ' </table>\n'
1873
+ ' </div>\n'
1874
+ '\n'
1875
+ ' <!-- Модальные окна деталей заказов -->\n'
1876
+ ' {% for order in orders %}\n'
1877
+ ' <div class="modal fade" id="orderModal{{ order.id }}" tabindex="-1" aria-hidden="true">\n'
1878
+ ' <div class="modal-dialog">\n'
1879
+ ' <div class="modal-content">\n'
1880
+ ' <div class="modal-header">\n'
1881
+ ' <h5 class="modal-title">Заказ #{{ order.id }}</h5>\n'
1882
+ ' <button type="button" class="btn-close" data-bs-dismiss="modal"></button>\n'
1883
+ ' </div>\n'
1884
+ ' <div class="modal-body">\n'
1885
+ ' <dl class="row mb-0">\n'
1886
+ ' <dt class="col-sm-5">Дата:</dt>\n'
1887
+ ' <dd class="col-sm-7">{{ order.created_at.strftime(\'%d.%m.%Y %H:%M\') }}</dd>\n'
1888
+ ' <dt class="col-sm-5">Статус:</dt>\n'
1889
+ ' <dd class="col-sm-7">{{ order.status }}</dd>\n'
1890
+ ' <dt class="col-sm-5">Сумма:</dt>\n'
1891
+ ' <dd class="col-sm-7">{{ "%.2f"|format(order.total_price) }} ₽</dd>\n'
1892
+ + order_detail_fields +
1893
+ ' </dl>\n'
1894
+ ' <hr>\n'
1895
+ ' <h6>Товары:</h6>\n'
1896
+ ' <ul class="list-unstyled">\n'
1897
+ ' {% for item in order.items %}\n'
1898
+ ' <li>{{ item.product_name }} × {{ item.quantity }} — {{ "%.2f"|format(item.price * item.quantity) }} ₽</li>\n'
1899
+ ' {% endfor %}\n'
1900
+ ' </ul>\n'
1901
+ ' </div>\n'
1902
+ ' <div class="modal-footer">\n'
1903
+ ' <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>\n'
1904
+ ' </div>\n'
1905
+ ' </div>\n'
1906
+ ' </div>\n'
1907
+ ' </div>\n'
1908
+ ' {% endfor %}\n'
1909
+ '\n'
1910
+ ' {% else %}\n'
1911
+ ' <div class="text-center py-5 text-muted">\n'
1912
+ ' <div class="fs-1">🛒</div>\n'
1913
+ ' <p>У вас пока нет покупок.</p>\n'
1914
+ ' <a href="{{ url_for(\'catalog\') }}" class="btn btn-primary">Перейти в каталог</a>\n'
1915
+ ' </div>\n'
1916
+ ' {% endif %}\n'
1917
+ ' </div>\n'
1918
+ ' </div>\n'
1919
+ ' </div>'
1920
+ )
1921
+ else:
1922
+ dashboard_main_content = (
1923
+ ' <!-- Заявки -->\n'
1924
+ ' <div class="col-12">\n'
1925
+ ' <div class="card border-0 shadow-sm">\n'
1926
+ ' <div class="card-header bg-info text-white d-flex justify-content-between align-items-center">\n'
1927
+ ' <h5 class="mb-0">📋 Мои заявки</h5>\n'
1928
+ ' <span class="badge bg-white text-dark">{{ requests|length }}</span>\n'
1929
+ ' </div>\n'
1930
+ ' <div class="card-body">\n'
1931
+ ' <!-- Фильтр -->\n'
1932
+ ' <div class="mb-3 d-flex flex-wrap gap-2">\n'
1933
+ ' <a href="{{ url_for(\'dashboard\') }}"\n'
1934
+ ' class="btn btn-sm {% if current_status == \'all\' %}btn-dark{% else %}btn-outline-dark{% endif %}">\n'
1935
+ ' Все\n'
1936
+ ' </a>\n'
1937
+ ' {% for s in status_list %}\n'
1938
+ ' <a href="{{ url_for(\'dashboard\', status=s) }}"\n'
1939
+ ' class="btn btn-sm {% if current_status == s %}btn-primary{% else %}btn-outline-primary{% endif %}">\n'
1940
+ ' {{ s }}\n'
1941
+ ' </a>\n'
1942
+ ' {% endfor %}\n'
1943
+ ' </div>\n'
1944
+ '\n'
1945
+ ' {% if requests %}\n'
1946
+ ' <div class="table-responsive">\n'
1947
+ ' <table class="table table-hover align-middle">\n'
1948
+ ' <thead class="table-light">\n'
1949
+ ' <tr>\n'
1950
+ ' <th>#</th>\n'
1951
+ ' <th>Дата</th>\n'
1952
+ ' <th>Статус</th>\n'
1953
+ ' <th>Детали</th>\n'
1954
+ ' <th>Действия</th>\n'
1955
+ ' </tr>\n'
1956
+ ' </thead>\n'
1957
+ ' <tbody>\n'
1958
+ ' {% for req in requests %}\n'
1959
+ ' <tr class="request-row" id="row-{{ req.id }}">\n'
1960
+ ' <td class="text-muted">{{ req.id }}</td>\n'
1961
+ ' <td>{{ req.created_at.strftime(\'%d.%m.%Y\') }}<br>\n'
1962
+ ' <small class="text-muted">{{ req.created_at.strftime(\'%H:%M\') }}</small>\n'
1963
+ ' </td>\n'
1964
+ ' <td>\n'
1965
+ ' {% set status_colors = {\'Новая\': \'warning\', \'В работе\': \'info\', \'Завершена\': \'success\'} %}\n'
1966
+ ' <span class="badge bg-{{ status_colors.get(req.status, \'secondary\') }}\n'
1967
+ ' {% if req.status == \'Новая\' %}text-dark{% endif %}">\n'
1968
+ ' {{ req.status }}\n'
1969
+ ' </span>\n'
1970
+ ' </td>\n'
1971
+ ' <td>\n'
1972
+ ' <button type="button" class="btn btn-sm btn-outline-secondary"\n'
1973
+ ' data-bs-toggle="modal" data-bs-target="#modal{{ req.id }}">\n'
1974
+ ' 👁 Подробнее\n'
1975
+ ' </button>\n'
1976
+ ' </td>\n'
1977
+ ' <td>\n'
1978
+ ' {% if req.status == status_list[0] %}\n'
1979
+ ' <form method="POST" action="{{ url_for(\'delete_request\', req_id=req.id) }}"\n'
1980
+ ' onsubmit="return confirm(\'Удалить заявку #{{ req.id }}?\')"\n'
1981
+ ' class="d-inline">\n'
1982
+ ' <button type="submit" class="btn btn-sm btn-outline-danger delete-btn"\n'
1983
+ ' data-row="row-{{ req.id }}">\n'
1984
+ ' 🗑 Удалить\n'
1985
+ ' </button>\n'
1986
+ ' </form>\n'
1987
+ ' {% else %}\n'
1988
+ ' <span class="text-muted small">—</span>\n'
1989
+ ' {% endif %}\n'
1990
+ ' </td>\n'
1991
+ ' </tr>\n'
1992
+ ' {% endfor %}\n'
1993
+ ' </tbody>\n'
1994
+ ' </table>\n'
1995
+ ' </div>\n'
1996
+ '\n'
1997
+ ' <!-- Модальные окна -->\n'
1998
+ ' {% for req in requests %}\n'
1999
+ ' <div class="modal fade" id="modal{{ req.id }}" tabindex="-1"\n'
2000
+ ' aria-labelledby="modalLabel{{ req.id }}" aria-hidden="true">\n'
2001
+ ' <div class="modal-dialog">\n'
2002
+ ' <div class="modal-content">\n'
2003
+ ' <div class="modal-header">\n'
2004
+ ' <h5 class="modal-title" id="modalLabel{{ req.id }}">\n'
2005
+ ' Заявка #{{ req.id }}\n'
2006
+ ' </h5>\n'
2007
+ ' <button type="button" class="btn-close" data-bs-dismiss="modal"\n'
2008
+ ' aria-label="Закрыть"></button>\n'
2009
+ ' </div>\n'
2010
+ ' <div class="modal-body">\n'
2011
+ ' <dl class="row mb-0">\n'
2012
+ ' <dt class="col-sm-5">Дата создания:</dt>\n'
2013
+ ' <dd class="col-sm-7">{{ req.created_at.strftime(\'%d.%m.%Y %H:%M\') }}</dd>\n'
2014
+ ' <dt class="col-sm-5">Статус:</dt>\n'
2015
+ ' <dd class="col-sm-7">{{ req.status }}</dd>\n'
2016
+ ' {{REQUEST_MODAL_FIELDS}}\n'
2017
+ ' </dl>\n'
2018
+ ' {{PHOTO_MODAL_CONTENT}}\n'
2019
+ ' </div>\n'
2020
+ ' <div class="modal-footer">\n'
2021
+ ' <button type="button" class="btn btn-secondary"\n'
2022
+ ' data-bs-dismiss="modal">Закрыть</button>\n'
2023
+ ' </div>\n'
2024
+ ' </div>\n'
2025
+ ' </div>\n'
2026
+ ' </div>\n'
2027
+ ' {% endfor %}\n'
2028
+ '\n'
2029
+ ' {% else %}\n'
2030
+ ' <div class="text-center py-5 text-muted">\n'
2031
+ ' <div class="fs-1">📭</div>\n'
2032
+ ' <p>У вас пока нет заявок.</p>\n'
2033
+ ' <a href="{{ url_for(\'new_request\') }}" class="btn btn-primary">Создать первую заявку</a>\n'
2034
+ ' </div>\n'
2035
+ ' {% endif %}\n'
2036
+ ' </div>\n'
2037
+ ' </div>\n'
2038
+ ' </div>'
2039
+ )
2040
+ html = html.replace("{{DASHBOARD_MAIN_CONTENT}}", dashboard_main_content)
2041
+ html = html.replace("{{DASHBOARD_ACTION_BUTTONS}}", dashboard_action_buttons if 'dashboard_action_buttons' in dir() else "")
2042
+
2043
+ # ── {{ADMIN_SERVICE_MODAL}} ───────────────────────────────────────
2044
+ if config.get("services"):
2045
+ modal_fields = []
2046
+ # Название — всегда
2047
+ modal_fields.append(
2048
+ ' <div class="mb-3">\n'
2049
+ ' <label class="form-label">Название <span class="text-danger">*</span></label>\n'
2050
+ ' <input type="text" name="name" class="form-control" required>\n'
2051
+ ' </div>'
2052
+ )
2053
+ if config.get("svc_description"):
2054
+ modal_fields.append(
2055
+ ' <div class="mb-3">\n'
2056
+ ' <label class="form-label">Описание</label>\n'
2057
+ ' <textarea name="description" class="form-control" rows="3"></textarea>\n'
2058
+ ' </div>'
2059
+ )
2060
+ if config.get("svc_price"):
2061
+ modal_fields.append(
2062
+ ' <div class="mb-3">\n'
2063
+ ' <label class="form-label">Цена (₽)</label>\n'
2064
+ ' <input type="number" name="price" class="form-control" step="0.01" min="0">\n'
2065
+ ' </div>'
2066
+ )
2067
+ if config.get("svc_duration"):
2068
+ modal_fields.append(
2069
+ ' <div class="mb-3">\n'
2070
+ ' <label class="form-label">Длительность</label>\n'
2071
+ ' <input type="text" name="duration" class="form-control" placeholder="напр. 2 часа">\n'
2072
+ ' </div>'
2073
+ )
2074
+ if config.get("svc_category"):
2075
+ modal_fields.append(
2076
+ ' <div class="mb-3">\n'
2077
+ ' <label class="form-label">Категория</label>\n'
2078
+ ' <input type="text" name="category" class="form-control">\n'
2079
+ ' </div>'
2080
+ )
2081
+ if config.get("svc_image"):
2082
+ modal_fields.append(
2083
+ ' <div class="mb-3">\n'
2084
+ ' <label class="form-label">Изображение</label>\n'
2085
+ ' <input type="file" name="image" class="form-control" accept="image/*">\n'
2086
+ ' </div>'
2087
+ )
2088
+ if config.get("svc_max_clients"):
2089
+ modal_fields.append(
2090
+ ' <div class="mb-3">\n'
2091
+ ' <label class="form-label">Макс. кол-во клиентов</label>\n'
2092
+ ' <input type="number" name="max_clients" class="form-control" min="1">\n'
2093
+ ' </div>'
2094
+ )
2095
+ if config.get("svc_location"):
2096
+ modal_fields.append(
2097
+ ' <div class="mb-3">\n'
2098
+ ' <label class="form-label">Место проведения</label>\n'
2099
+ ' <input type="text" name="location" class="form-control">\n'
2100
+ ' </div>'
2101
+ )
2102
+ if config.get("svc_requirements"):
2103
+ modal_fields.append(
2104
+ ' <div class="mb-3">\n'
2105
+ ' <label class="form-label">Требования к клиенту</label>\n'
2106
+ ' <textarea name="requirements" class="form-control" rows="2"></textarea>\n'
2107
+ ' </div>'
2108
+ )
2109
+ if config.get("svc_is_active"):
2110
+ modal_fields.append(
2111
+ ' <div class="mb-3 form-check">\n'
2112
+ ' <input type="checkbox" name="is_active" class="form-check-input" id="modalIsActive" checked>\n'
2113
+ ' <label class="form-check-label" for="modalIsActive">Активна (отображается на сайте)</label>\n'
2114
+ ' </div>'
2115
+ )
2116
+
2117
+ fields_html = "\n".join(modal_fields)
2118
+ admin_svc_modal = (
2119
+ '<!-- Модальное окно: Добавить услугу -->\n'
2120
+ '<div class="modal fade" id="addServiceModal" tabindex="-1" aria-labelledby="addServiceModalLabel" aria-hidden="true">\n'
2121
+ ' <div class="modal-dialog">\n'
2122
+ ' <div class="modal-content">\n'
2123
+ ' <div class="modal-header">\n'
2124
+ ' <h5 class="modal-title" id="addServiceModalLabel">Добавить услугу</h5>\n'
2125
+ ' <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>\n'
2126
+ ' </div>\n'
2127
+ ' <form method="POST" action="{{ url_for(\'admin_service_add\') }}" enctype="multipart/form-data">\n'
2128
+ ' <div class="modal-body">\n'
2129
+ + fields_html + '\n'
2130
+ ' </div>\n'
2131
+ ' <div class="modal-footer">\n'
2132
+ ' <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>\n'
2133
+ ' <button type="submit" class="btn btn-success">Добавить</button>\n'
2134
+ ' </div>\n'
2135
+ ' </form>\n'
2136
+ ' </div>\n'
2137
+ ' </div>\n'
2138
+ '</div>'
2139
+ )
2140
+ else:
2141
+ admin_svc_modal = "<!-- модуль услуг не используется -->"
2142
+ html = html.replace("{{ADMIN_SERVICE_MODAL}}", admin_svc_modal)
2143
+
2144
+ # ── {{SVC_FORM_FIELDS}} ───────────────────────────────────────────
2145
+ if config.get("services"):
2146
+ form_fields = []
2147
+ if config.get("svc_price"):
2148
+ form_fields.append(
2149
+ ' <div class="col-md-3">\n'
2150
+ ' <label class="form-label">Цена (₽)</label>\n'
2151
+ ' <input type="number" name="price" class="form-control" step="0.01" min="0"\n'
2152
+ ' value="{{ service.price if service and service.price else \'\' }}">\n'
2153
+ ' </div>'
2154
+ )
2155
+ if config.get("svc_duration"):
2156
+ form_fields.append(
2157
+ ' <div class="col-md-3">\n'
2158
+ ' <label class="form-label">Длительность</label>\n'
2159
+ ' <input type="text" name="duration" class="form-control" placeholder="напр. 2 часа"\n'
2160
+ ' value="{{ service.duration if service and service.duration else \'\' }}">\n'
2161
+ ' </div>'
2162
+ )
2163
+ if config.get("svc_category"):
2164
+ form_fields.append(
2165
+ ' <div class="col-md-4">\n'
2166
+ ' <label class="form-label">Категория</label>\n'
2167
+ ' <input type="text" name="category" class="form-control"\n'
2168
+ ' value="{{ service.category if service and service.category else \'\' }}">\n'
2169
+ ' </div>'
2170
+ )
2171
+ if config.get("svc_location"):
2172
+ form_fields.append(
2173
+ ' <div class="col-md-4">\n'
2174
+ ' <label class="form-label">Место проведения</label>\n'
2175
+ ' <input type="text" name="location" class="form-control"\n'
2176
+ ' value="{{ service.location if service and service.location else \'\' }}">\n'
2177
+ ' </div>'
2178
+ )
2179
+ if config.get("svc_max_clients"):
2180
+ form_fields.append(
2181
+ ' <div class="col-md-4">\n'
2182
+ ' <label class="form-label">Макс. клиентов</label>\n'
2183
+ ' <input type="number" name="max_clients" class="form-control" min="1"\n'
2184
+ ' value="{{ service.max_clients if service and service.max_clients else \'\' }}">\n'
2185
+ ' </div>'
2186
+ )
2187
+ if config.get("svc_description"):
2188
+ form_fields.append(
2189
+ ' <div class="col-12">\n'
2190
+ ' <label class="form-label">Описание</label>\n'
2191
+ ' <textarea name="description" class="form-control" rows="3">{{ service.description if service and service.description else \'\' }}</textarea>\n'
2192
+ ' </div>'
2193
+ )
2194
+ if config.get("svc_requirements"):
2195
+ form_fields.append(
2196
+ ' <div class="col-12">\n'
2197
+ ' <label class="form-label">Требования</label>\n'
2198
+ ' <textarea name="requirements" class="form-control" rows="2">{{ service.requirements if service and service.requirements else \'\' }}</textarea>\n'
2199
+ ' </div>'
2200
+ )
2201
+ if config.get("svc_image"):
2202
+ form_fields.append(
2203
+ ' <div class="col-md-6">\n'
2204
+ ' <label class="form-label">Изображение</label>\n'
2205
+ ' <input type="file" name="image" class="form-control" accept="image/*">\n'
2206
+ ' {% if service and service.image %}\n'
2207
+ ' <small class="text-muted">Текущее: {{ service.image }}</small>\n'
2208
+ ' {% endif %}\n'
2209
+ ' </div>'
2210
+ )
2211
+ if config.get("svc_is_active"):
2212
+ form_fields.append(
2213
+ ' <div class="col-md-6 d-flex align-items-end">\n'
2214
+ ' <div class="form-check">\n'
2215
+ ' <input type="checkbox" name="is_active" class="form-check-input" id="is_active"\n'
2216
+ ' {% if not service or service.is_active %}checked{% endif %}>\n'
2217
+ ' <label class="form-check-label" for="is_active">Активна (отображается на сайте)</label>\n'
2218
+ ' </div>\n'
2219
+ ' </div>'
2220
+ )
2221
+ html = html.replace("{{SVC_FORM_FIELDS}}", "\n".join(form_fields) if form_fields else "<!-- нет доп. полей -->")
2222
+ else:
2223
+ html = html.replace("{{SVC_FORM_FIELDS}}", "<!-- услуги не используются -->")
2224
+
2225
+ # ── {{SVC_TABLE_HEADERS}} и {{SVC_TABLE_CELLS}} ───────────────────
2226
+ if config.get("services"):
2227
+ headers = []
2228
+ cells = []
2229
+ if config.get("svc_price"):
2230
+ headers.append(' <th>Цена</th>')
2231
+ cells.append(' <td>{% if svc.price %}{{ "%.2f"|format(svc.price) }} ₽{% else %}—{% endif %}</td>')
2232
+ if config.get("svc_category"):
2233
+ headers.append(' <th>Категория</th>')
2234
+ cells.append(' <td>{% if svc.category %}{{ svc.category }}{% else %}—{% endif %}</td>')
2235
+ if config.get("svc_is_active"):
2236
+ headers.append(' <th>Статус</th>')
2237
+ cells.append(
2238
+ ' <td>\n'
2239
+ ' {% if svc.is_active %}\n'
2240
+ ' <span class="badge bg-success">Активна</span>\n'
2241
+ ' {% else %}\n'
2242
+ ' <span class="badge bg-secondary">Скрыта</span>\n'
2243
+ ' {% endif %}\n'
2244
+ ' </td>'
2245
+ )
2246
+ html = html.replace("{{SVC_TABLE_HEADERS}}", "\n".join(headers) if headers else "")
2247
+ html = html.replace("{{SVC_TABLE_CELLS}}", "\n".join(cells) if cells else "")
2248
+ else:
2249
+ html = html.replace("{{SVC_TABLE_HEADERS}}", "")
2250
+ html = html.replace("{{SVC_TABLE_CELLS}}", "")
2251
+
2252
+ # ── {{SVC_DETAIL_IMAGE}} ──────────────────────────────────────────
2253
+ if config.get("services") and config.get("svc_image"):
2254
+ svc_detail_image = (
2255
+ '{% if service.image %}\n'
2256
+ ' <img src="{{ url_for(\'static\', filename=\'uploads/services/\' + service.image) }}"\n'
2257
+ ' class="img-fluid rounded shadow" alt="{{ service.name }}">\n'
2258
+ ' {% else %}\n'
2259
+ ' <div class="bg-light rounded d-flex align-items-center justify-content-center" style="height:300px;">\n'
2260
+ ' <span class="text-muted fs-1">📷</span>\n'
2261
+ ' </div>\n'
2262
+ ' {% endif %}'
2263
+ )
2264
+ else:
2265
+ svc_detail_image = (
2266
+ '<div class="bg-light rounded d-flex align-items-center justify-content-center" style="height:300px;">\n'
2267
+ ' <span class="text-muted fs-1">🛠</span>\n'
2268
+ ' </div>'
2269
+ )
2270
+ html = html.replace("{{SVC_DETAIL_IMAGE}}", svc_detail_image)
2271
+
2272
+ # ── {{SVC_DETAIL_FIELDS}} ─────────────────────────────────────────
2273
+ if config.get("services"):
2274
+ detail_parts = []
2275
+ if config.get("svc_price"):
2276
+ detail_parts.append(
2277
+ ' {% if service.price %}\n'
2278
+ ' <p class="fs-4 text-success fw-bold mb-2">{{ "%.2f"|format(service.price) }} ₽</p>\n'
2279
+ ' {% endif %}'
2280
+ )
2281
+ if config.get("svc_category"):
2282
+ detail_parts.append(
2283
+ ' {% if service.category %}\n'
2284
+ ' <p><strong>Категория:</strong> {{ service.category }}</p>\n'
2285
+ ' {% endif %}'
2286
+ )
2287
+ if config.get("svc_duration"):
2288
+ detail_parts.append(
2289
+ ' {% if service.duration %}\n'
2290
+ ' <p><strong>Длительность:</strong> {{ service.duration }}</p>\n'
2291
+ ' {% endif %}'
2292
+ )
2293
+ if config.get("svc_location"):
2294
+ detail_parts.append(
2295
+ ' {% if service.location %}\n'
2296
+ ' <p><strong>Место:</strong> {{ service.location }}</p>\n'
2297
+ ' {% endif %}'
2298
+ )
2299
+ if config.get("svc_max_clients"):
2300
+ detail_parts.append(
2301
+ ' {% if service.max_clients %}\n'
2302
+ ' <p><strong>Макс. клиентов:</strong> {{ service.max_clients }}</p>\n'
2303
+ ' {% endif %}'
2304
+ )
2305
+ if config.get("svc_description"):
2306
+ detail_parts.append(
2307
+ ' {% if service.description %}\n'
2308
+ ' <div class="mb-3">\n'
2309
+ ' <h5>Описание</h5>\n'
2310
+ ' <p>{{ service.description }}</p>\n'
2311
+ ' </div>\n'
2312
+ ' {% endif %}'
2313
+ )
2314
+ if config.get("svc_requirements"):
2315
+ detail_parts.append(
2316
+ ' {% if service.requirements %}\n'
2317
+ ' <div class="mb-3">\n'
2318
+ ' <h5>Требования</h5>\n'
2319
+ ' <p>{{ service.requirements }}</p>\n'
2320
+ ' </div>\n'
2321
+ ' {% endif %}'
2322
+ )
2323
+ html = html.replace("{{SVC_DETAIL_FIELDS}}", "\n".join(detail_parts) if detail_parts else "")
2324
+ else:
2325
+ html = html.replace("{{SVC_DETAIL_FIELDS}}", "")
2326
+
2327
+ # ── {{REQUEST_FIELDS_HTML}} ───────────────────────────────────────
2328
+ req_fields_html_parts = []
2329
+ for field in config.get("custom_request_fields", []):
2330
+ req_fields_html_parts.append(_field_to_html_input(field))
2331
+ html = html.replace(
2332
+ "{{REQUEST_FIELDS_HTML}}",
2333
+ "\n".join(req_fields_html_parts) if req_fields_html_parts else "<!-- нет полей заявки -->"
2334
+ )
2335
+
2336
+ # ── {{PHOTO_BEFORE_FIELD}} ────────────────────────────────────────
2337
+ if config.get("photo_before_after"):
2338
+ photo_before_field = (
2339
+ ' <div class="mb-3">\n'
2340
+ ' <label for="photo_before" class="form-label fw-semibold">Фото «до»</label>\n'
2341
+ ' <input type="file" class="form-control" id="photo_before" name="photo_before" accept="image/*">\n'
2342
+ ' <img id="photoBeforePreview" class="mt-2 img-fluid rounded d-none" style="max-height:200px;">\n'
2343
+ ' </div>'
2344
+ )
2345
+ else:
2346
+ photo_before_field = "<!-- фото до не используется -->"
2347
+ html = html.replace("{{PHOTO_BEFORE_FIELD}}", photo_before_field)
2348
+
2349
+ # ── {{PHOTO_MODAL_CONTENT}} ───────────────────────────────────────
2350
+ if config.get("photo_before_after"):
2351
+ photo_modal_content = (
2352
+ '{% if req.photo_before %}\n'
2353
+ ' <hr>\n'
2354
+ ' <p class="fw-semibold mb-1">Фото ДО:</p>\n'
2355
+ ' <img src="{{ url_for(\'static\', filename=\'uploads/requests/\' + req.photo_before) }}"\n'
2356
+ ' class="img-fluid rounded mb-2" style="max-height:200px;">\n'
2357
+ ' {% endif %}\n'
2358
+ ' {% if req.photo_after %}\n'
2359
+ ' <p class="fw-semibold mb-1">Фото ПОСЛЕ:</p>\n'
2360
+ ' <img src="{{ url_for(\'static\', filename=\'uploads/requests/\' + req.photo_after) }}"\n'
2361
+ ' class="img-fluid rounded" style="max-height:200px;">\n'
2362
+ ' {% endif %}'
2363
+ )
2364
+ else:
2365
+ photo_modal_content = "<!-- фото до/после не используется -->"
2366
+ html = html.replace("{{PHOTO_MODAL_CONTENT}}", photo_modal_content)
2367
+
2368
+ # ── {{REQUEST_MODAL_FIELDS}} (кастомные поля в модальном окне) ────
2369
+ modal_fields_parts = []
2370
+ for field in config.get("custom_request_fields", []):
2371
+ name = field["name"]
2372
+ label = field["label"]
2373
+ modal_fields_parts.append(
2374
+ f' {{% if req.{name} is defined and req.{name} %}}\n'
2375
+ f' <dt class="col-sm-5">{label}:</dt>\n'
2376
+ f' <dd class="col-sm-7">{{{{ req.{name} }}}}</dd>\n'
2377
+ f' {{% endif %}}'
2378
+ )
2379
+ html = html.replace(
2380
+ "{{REQUEST_MODAL_FIELDS}}",
2381
+ "\n".join(modal_fields_parts) if modal_fields_parts else ""
2382
+ )
2383
+
2384
+ # ── {{ADMIN_REQUEST_TABLE_HEADERS}} и {{ADMIN_REQUEST_TABLE_CELLS}} ─
2385
+ admin_req_headers = []
2386
+ admin_req_cells = []
2387
+ for field in config.get("custom_request_fields", []):
2388
+ name = field["name"]
2389
+ label = field["label"]
2390
+ admin_req_headers.append(f' <th>{label}</th>')
2391
+ admin_req_cells.append(
2392
+ f' <td>{{{{ req.{name} or "—" }}}}</td>'
2393
+ )
2394
+ html = html.replace(
2395
+ "{{ADMIN_REQUEST_TABLE_HEADERS}}",
2396
+ "\n".join(admin_req_headers) if admin_req_headers else ""
2397
+ )
2398
+ html = html.replace(
2399
+ "{{ADMIN_REQUEST_TABLE_CELLS}}",
2400
+ "\n".join(admin_req_cells) if admin_req_cells else ""
2401
+ )
2402
+
2403
+ # ── {{CUSTOM_PRODUCT_FIELDS}} (кастомные поля товара в форме) ─
2404
+ custom_product_form = []
2405
+ for field in config.get("custom_product_fields", []):
2406
+ name = field["name"]
2407
+ label = field["label"]
2408
+ ftype = field["type"]
2409
+ options = field.get("options", [])
2410
+ if ftype == "select" and options:
2411
+ opts_html = ' <option value="">— Выберите —</option>\n'
2412
+ for opt in options:
2413
+ opts_html += f' <option value="{opt}" {{% if product and product.{name} == \'{opt}\' %}}selected{{% endif %}}>{opt}</option>\n'
2414
+ custom_product_form.append(
2415
+ f' <div class="mb-3">\n'
2416
+ f' <label class="form-label fw-semibold">{label}</label>\n'
2417
+ f' <select class="form-select" name="{name}">\n'
2418
+ f'{opts_html}'
2419
+ f' </select>\n'
2420
+ f' </div>'
2421
+ )
2422
+ elif ftype == "textarea":
2423
+ custom_product_form.append(
2424
+ f' <div class="mb-3">\n'
2425
+ f' <label class="form-label fw-semibold">{label}</label>\n'
2426
+ f' <textarea class="form-control" name="{name}" rows="3">{{{{ product.{name} if product and product.{name} else \'\' }}}}</textarea>\n'
2427
+ f' </div>'
2428
+ )
2429
+ elif ftype == "number":
2430
+ custom_product_form.append(
2431
+ f' <div class="mb-3">\n'
2432
+ f' <label class="form-label fw-semibold">{label}</label>\n'
2433
+ f' <input type="number" class="form-control" name="{name}"\n'
2434
+ f' value="{{{{ product.{name} if product and product.{name} else \'\' }}}}">\n'
2435
+ f' </div>'
2436
+ )
2437
+ else:
2438
+ custom_product_form.append(
2439
+ f' <div class="mb-3">\n'
2440
+ f' <label class="form-label fw-semibold">{label}</label>\n'
2441
+ f' <input type="text" class="form-control" name="{name}"\n'
2442
+ f' value="{{{{ product.{name} if product and product.{name} else \'\' }}}}">\n'
2443
+ f' </div>'
2444
+ )
2445
+ html = html.replace(
2446
+ "{{CUSTOM_PRODUCT_FIELDS}}",
2447
+ "\n".join(custom_product_form) if custom_product_form else ""
2448
+ )
2449
+
2450
+ # ── {{CHECKOUT_FIELDS_HTML}} (поля формы оформления заказа) ─
2451
+ checkout_fields_html = []
2452
+ for field in config.get("checkout_fields", []):
2453
+ checkout_fields_html.append(_field_to_html_input(field))
2454
+ html = html.replace(
2455
+ "{{CHECKOUT_FIELDS_HTML}}",
2456
+ "\n".join(checkout_fields_html) if checkout_fields_html else '<p class="text-muted">Нажмите кнопку для оформления заказа.</p>'
2457
+ )
2458
+
2459
+ # ── {{CHECKOUT_PHONE_MASK}} (маска телефона на странице оформления) ─
2460
+ has_phone_checkout = any(f["type"] == "phone" for f in config.get("checkout_fields", []))
2461
+ if has_phone_checkout:
2462
+ phone_field_names = [f["name"] for f in config.get("checkout_fields", []) if f["type"] == "phone"]
2463
+ mask_lines = ['<script>']
2464
+ mask_lines.append("document.addEventListener('DOMContentLoaded', function() {")
2465
+ for fname in phone_field_names:
2466
+ mask_lines.append(f" var el = document.getElementById('{fname}');")
2467
+ mask_lines.append(f" if (el) {{ Inputmask('+7 (999) 999-99-99').mask(el); }}")
2468
+ mask_lines.append("});")
2469
+ mask_lines.append('</script>')
2470
+ html = html.replace("{{CHECKOUT_PHONE_MASK}}", "\n".join(mask_lines))
2471
+ else:
2472
+ html = html.replace("{{CHECKOUT_PHONE_MASK}}", "")
2473
+
2474
+ # ── {{ADMIN_ORDER_DETAIL_FIELDS}} (поля заказа в модальном окне админки) ─
2475
+ admin_order_detail = ""
2476
+ for field in config.get("checkout_fields", []):
2477
+ name = field["name"]
2478
+ label = field["label"]
2479
+ admin_order_detail += (
2480
+ f' <dt class="col-sm-5">{label}:</dt>\n'
2481
+ f' <dd class="col-sm-7">{{{{ order.{name} or "—" }}}}</dd>\n'
2482
+ )
2483
+ html = html.replace("{{ADMIN_ORDER_DETAIL_FIELDS}}", admin_order_detail)
2484
+
2485
+ # ── {{INDEX_SUBTITLE}} и {{INDEX_COUNTER_BLOCK}} ──────────────────
2486
+ if config.get("cart") and config.get("cart_type") == "products":
2487
+ html = html.replace("{{INDEX_SUBTITLE}}", "Лучшие товары для вас")
2488
+ counter_block = (
2489
+ '<!-- Счётчик завершённых заказов -->\n'
2490
+ '<div class="row justify-content-center mb-5">\n'
2491
+ ' <div class="col-md-4 col-sm-6">\n'
2492
+ ' <div class="card text-center shadow-sm {{ANIMATE_CLASS}}">\n'
2493
+ ' <div class="card-body py-4">\n'
2494
+ ' <div class="display-4 fw-bold text-success counter-value" id="completedCount">\n'
2495
+ ' {{ completed_count }}\n'
2496
+ ' </div>\n'
2497
+ ' <p class="text-muted mb-0">Выполненных заказов</p>\n'
2498
+ ' </div>\n'
2499
+ ' </div>\n'
2500
+ ' </div>\n'
2501
+ '</div>'
2502
+ )
2503
+ else:
2504
+ html = html.replace("{{INDEX_SUBTITLE}}", "Профессиональные услуги для вас")
2505
+ counter_block = (
2506
+ '<!-- Счётчик завершённых заявок -->\n'
2507
+ '<div class="row justify-content-center mb-5">\n'
2508
+ ' <div class="col-md-4 col-sm-6">\n'
2509
+ ' <div class="card text-center shadow-sm {{ANIMATE_CLASS}}">\n'
2510
+ ' <div class="card-body py-4">\n'
2511
+ ' <div class="display-4 fw-bold text-success counter-value" id="completedCount">\n'
2512
+ ' {{ completed_count }}\n'
2513
+ ' </div>\n'
2514
+ ' <p class="text-muted mb-0">Завершённых заявок</p>\n'
2515
+ ' </div>\n'
2516
+ ' </div>\n'
2517
+ ' </div>\n'
2518
+ '</div>'
2519
+ )
2520
+ html = html.replace("{{INDEX_COUNTER_BLOCK}}", counter_block)
2521
+
2522
+ # ── {{CHECKOUT_FORM}} (старый плейсхолдер — убираем) ─
2523
+ html = html.replace("{{CHECKOUT_FORM}}", "")
2524
+
2525
+ # ── {{CUSTOM_USER_REG_FIELDS}} (кастомные поля пользователя в регистрации) ─
2526
+ custom_user_reg = []
2527
+ for field in config.get("custom_user_fields", []):
2528
+ custom_user_reg.append(_field_to_html_input(field))
2529
+ html = html.replace(
2530
+ "{{CUSTOM_USER_REG_FIELDS}}",
2531
+ "\n".join(custom_user_reg) if custom_user_reg else ""
2532
+ )
2533
+
2534
+ # ── {{CUSTOM_USER_PROFILE_FIELDS}} (кастомные поля пользователя в профиле) ─
2535
+ custom_user_profile = []
2536
+ for field in config.get("custom_user_fields", []):
2537
+ name = field["name"]
2538
+ label = field["label"]
2539
+ custom_user_profile.append(
2540
+ f' <div class="mb-3">\n'
2541
+ f' <label class="form-label fw-semibold">{label}</label>\n'
2542
+ f' <input type="text" class="form-control" name="{name}"\n'
2543
+ f' value="{{{{ user.{name} or \'\' }}}}">\n'
2544
+ f' </div>'
2545
+ )
2546
+ html = html.replace(
2547
+ "{{CUSTOM_USER_PROFILE_FIELDS}}",
2548
+ "\n".join(custom_user_profile) if custom_user_profile else ""
2549
+ )
2550
+
2551
+ # ── {{SERVICE_REVIEWS_BLOCK}} ─────────────────────────────────────
2552
+ if config.get("reviews") and config.get("services"):
2553
+ reviews_block = (
2554
+ '<!-- Блок отзывов -->\n'
2555
+ '<div class="container py-4">\n'
2556
+ ' <hr>\n'
2557
+ ' <h3 class="mb-4">💬 Отзывы</h3>\n'
2558
+ '\n'
2559
+ ' {% if reviews %}\n'
2560
+ ' {% for review in reviews %}\n'
2561
+ ' <div class="card mb-3 border-0 shadow-sm">\n'
2562
+ ' <div class="card-body">\n'
2563
+ ' <div class="d-flex justify-content-between align-items-start">\n'
2564
+ ' <div>\n'
2565
+ ' <strong>{{ review.reviewer.login }}</strong>\n'
2566
+ ' {% if review.reviewer.full_name %}<small class="text-muted">({{ review.reviewer.full_name }})</small>{% endif %}\n'
2567
+ ' <br>\n'
2568
+ ' <small class="text-muted">{{ review.created_at.strftime(\'%d.%m.%Y %H:%M\') }}</small>\n'
2569
+ ' </div>\n'
2570
+ ' <div>\n'
2571
+ ' {% for i in range(review.rating) %}⭐{% endfor %}\n'
2572
+ ' {% for i in range(5 - review.rating) %}☆{% endfor %}\n'
2573
+ ' </div>\n'
2574
+ ' </div>\n'
2575
+ ' <p class="mt-2 mb-1">{{ review.text }}</p>\n'
2576
+ ' {% if review.admin_reply %}\n'
2577
+ ' <div class="bg-light rounded p-2 mt-2">\n'
2578
+ ' <small class="text-muted fw-bold">Ответ администратора:</small><br>\n'
2579
+ ' <small>{{ review.admin_reply }}</small>\n'
2580
+ ' </div>\n'
2581
+ ' {% endif %}\n'
2582
+ ' {% if session.get(\'role\') == \'admin\' %}\n'
2583
+ ' <div class="mt-2 d-flex gap-2">\n'
2584
+ ' <form method="POST" action="{{ url_for(\'admin_delete_review\', review_id=review.id) }}" class="d-inline">\n'
2585
+ ' <button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm(\'Удалить отзыв?\')">🗑 Удалить</button>\n'
2586
+ ' </form>\n'
2587
+ ' <button type="button" class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#replyModal{{ review.id }}">💬 Ответить</button>\n'
2588
+ ' </div>\n'
2589
+ ' <!-- Модальное окно ответа -->\n'
2590
+ ' <div class="modal fade" id="replyModal{{ review.id }}" tabindex="-1" aria-hidden="true">\n'
2591
+ ' <div class="modal-dialog">\n'
2592
+ ' <div class="modal-content">\n'
2593
+ ' <form method="POST" action="{{ url_for(\'admin_reply_review\', review_id=review.id) }}">\n'
2594
+ ' <div class="modal-header">\n'
2595
+ ' <h5 class="modal-title">Ответ на отзыв</h5>\n'
2596
+ ' <button type="button" class="btn-close" data-bs-dismiss="modal"></button>\n'
2597
+ ' </div>\n'
2598
+ ' <div class="modal-body">\n'
2599
+ ' <textarea name="admin_reply" class="form-control" rows="3" required>{{ review.admin_reply or \'\' }}</textarea>\n'
2600
+ ' </div>\n'
2601
+ ' <div class="modal-footer">\n'
2602
+ ' <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>\n'
2603
+ ' <button type="submit" class="btn btn-primary">Сохранить</button>\n'
2604
+ ' </div>\n'
2605
+ ' </form>\n'
2606
+ ' </div>\n'
2607
+ ' </div>\n'
2608
+ ' </div>\n'
2609
+ ' {% endif %}\n'
2610
+ ' </div>\n'
2611
+ ' </div>\n'
2612
+ ' {% endfor %}\n'
2613
+ ' {% else %}\n'
2614
+ ' <p class="text-muted">Пока нет отзывов. Будьте первым!</p>\n'
2615
+ ' {% endif %}\n'
2616
+ '\n'
2617
+ ' <!-- Форма добавления отзыва (только для авторизованных) -->\n'
2618
+ ' {% if session.get(\'user_id\') and session.get(\'role\') != \'admin\' %}\n'
2619
+ ' <div class="card border-0 shadow-sm mt-3">\n'
2620
+ ' <div class="card-body">\n'
2621
+ ' <h5>Оставить отзыв</h5>\n'
2622
+ ' <form method="POST" action="{{ url_for(\'add_service_review\', service_id=service.id) }}">\n'
2623
+ ' <div class="mb-3">\n'
2624
+ ' <label class="form-label">Оценка</label>\n'
2625
+ ' <select name="rating" class="form-select" style="width:auto;">\n'
2626
+ ' <option value="5">⭐⭐⭐⭐⭐ (5)</option>\n'
2627
+ ' <option value="4">⭐⭐⭐⭐ (4)</option>\n'
2628
+ ' <option value="3">⭐⭐⭐ (3)</option>\n'
2629
+ ' <option value="2">⭐⭐ (2)</option>\n'
2630
+ ' <option value="1">⭐ (1)</option>\n'
2631
+ ' </select>\n'
2632
+ ' </div>\n'
2633
+ ' <div class="mb-3">\n'
2634
+ ' <label class="form-label">Текст отзыва</label>\n'
2635
+ ' <textarea name="text" class="form-control" rows="3" required placeholder="Напишите ваш отзыв..."></textarea>\n'
2636
+ ' </div>\n'
2637
+ ' <button type="submit" class="btn btn-primary">Отправить отзыв</button>\n'
2638
+ ' </form>\n'
2639
+ ' </div>\n'
2640
+ ' </div>\n'
2641
+ ' {% elif not session.get(\'user_id\') %}\n'
2642
+ ' <div class="alert alert-light mt-3">\n'
2643
+ ' <a href="{{ url_for(\'login\') }}">Войдите</a>, чтобы оставить отзыв.\n'
2644
+ ' </div>\n'
2645
+ ' {% endif %}\n'
2646
+ '</div>'
2647
+ )
2648
+ else:
2649
+ reviews_block = "<!-- отзывы не используются -->"
2650
+ html = html.replace("{{SERVICE_REVIEWS_BLOCK}}", reviews_block)
2651
+
2652
+ # ── {{ADMIN_PHOTO_MODAL}} ─────────────────────────────────────────
2653
+ if config.get("photo_before_after"):
2654
+ admin_photo_modal = (
2655
+ '{% if req.photo_before %}\n'
2656
+ ' <hr>\n'
2657
+ ' <p class="fw-semibold mb-1">Фото ДО:</p>\n'
2658
+ ' <img src="{{ url_for(\'static\', filename=\'uploads/requests/\' + req.photo_before) }}"\n'
2659
+ ' class="img-fluid rounded mb-2" style="max-height:200px;">\n'
2660
+ ' {% endif %}\n'
2661
+ ' {% if req.photo_after %}\n'
2662
+ ' <p class="fw-semibold mb-1">Фото ПОСЛЕ:</p>\n'
2663
+ ' <img src="{{ url_for(\'static\', filename=\'uploads/requests/\' + req.photo_after) }}"\n'
2664
+ ' class="img-fluid rounded" style="max-height:200px;">\n'
2665
+ ' {% endif %}'
2666
+ )
2667
+ else:
2668
+ admin_photo_modal = "<!-- фото до/после не используется -->"
2669
+ html = html.replace("{{ADMIN_PHOTO_MODAL}}", admin_photo_modal)
2670
+
2671
+ # ── {{PHOTO_AFTER_FIELD}} ─────────────────────────────────────────
2672
+ if config.get("photo_before_after"):
2673
+ photo_after_field = (
2674
+ '<div class="mt-3">\n'
2675
+ ' <label class="form-label">Фото ПОСЛЕ (при завершении):</label>\n'
2676
+ ' <input type="file" name="photo_after" class="form-control form-control-sm" accept="image/*">\n'
2677
+ ' </div>'
2678
+ )
2679
+ else:
2680
+ photo_after_field = "<!-- фото после не используется -->"
2681
+ html = html.replace("{{PHOTO_AFTER_FIELD}}", photo_after_field)
2682
+
2683
+ return html
2684
+
2685
+
2686
+ def copy_static_files(dest_dir, config):
2687
+ """Копирует статические файлы (CSS, JS, изображения)."""
2688
+ static_dir = dest_dir / "static"
2689
+ css_dir = static_dir / "css"
2690
+ js_dir = static_dir / "js"
2691
+ img_dir = static_dir / "img"
2692
+ uploads_dir = static_dir / "uploads"
2693
+
2694
+ for d in [css_dir, js_dir, img_dir,
2695
+ uploads_dir / "avatars",
2696
+ uploads_dir / "products",
2697
+ uploads_dir / "requests",
2698
+ uploads_dir / "services"]:
2699
+ d.mkdir(parents=True, exist_ok=True)
2700
+
2701
+ # CSS
2702
+ with open(css_dir / "style.css", "w", encoding="utf-8") as f:
2703
+ f.write(read_template("style_css_template.css"))
2704
+
2705
+ # JS слайдер
2706
+ with open(js_dir / "slider.js", "w", encoding="utf-8") as f:
2707
+ f.write(read_template("slider_js_template.js"))
2708
+
2709
+ # Заглушки изображений для слайдера (SVG)
2710
+ colors = ["#4e73df", "#1cc88a", "#36b9cc", "#f6c23e", "#e74a3b"]
2711
+ for i in range(1, 6):
2712
+ svg_lines = [
2713
+ '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="500">',
2714
+ ' <rect width="1200" height="500" fill="' + colors[i - 1] + '"/>',
2715
+ ' <text x="600" y="260" font-family="Arial" font-size="48" fill="white"',
2716
+ ' text-anchor="middle" dominant-baseline="middle">Слайд ' + str(i) + '</text>',
2717
+ '</svg>',
2718
+ ]
2719
+ with open(img_dir / ("slide" + str(i) + ".svg"), "w", encoding="utf-8") as f:
2720
+ f.write("\n".join(svg_lines))
2721
+
2722
+
2723
+ def build_html_templates(dest_dir, config):
2724
+ """Копирует HTML-шаблоны в папку templates сгенерированного сайта."""
2725
+ tmpl_dir = dest_dir / "templates"
2726
+ tmpl_dir.mkdir(exist_ok=True)
2727
+
2728
+ # Базовые шаблоны (всегда)
2729
+ files_map = {
2730
+ "base_html_template.html": "base.html",
2731
+ "index_html_template.html": "index.html",
2732
+ "login_html_template.html": "login.html",
2733
+ "register_html_template.html": "register.html",
2734
+ "dashboard_html_template.html": "dashboard.html",
2735
+ "new_request_html_template.html": "new_request.html",
2736
+ "admin_html_template.html": "admin.html",
2737
+ "admin_users_html_template.html": "admin_users.html",
2738
+ "profile_html_template.html": "profile.html",
2739
+ }
2740
+
2741
+ # Корзина и каталог
2742
+ if config.get("cart") and config.get("cart_type") == "products":
2743
+ files_map.update({
2744
+ "catalog_html_template.html": "catalog.html",
2745
+ "product_html_template.html": "product.html",
2746
+ "product_form_html_template.html": "product_form.html",
2747
+ "cart_html_template.html": "cart.html",
2748
+ "orders_html_template.html": "orders.html",
2749
+ "checkout_html_template.html": "checkout.html",
2750
+ "admin_cart_html_template.html": "admin.html",
2751
+ })
2752
+ # При корзине — заменяем стандартный admin.html на версию со статистикой продаж
2753
+ del files_map["admin_html_template.html"]
2754
+
2755
+ # Услуги
2756
+ if config.get("services"):
2757
+ files_map.update({
2758
+ "services_html_template.html": "services.html",
2759
+ "service_detail_html_template.html": "service_detail.html",
2760
+ "service_form_html_template.html": "service_form.html",
2761
+ })
2762
+
2763
+ # Общие отзывы (без корзины и без услуг)
2764
+ if config.get("reviews") and not (config.get("cart") and config.get("cart_type") == "products") and not config.get("services"):
2765
+ files_map["reviews_html_template.html"] = "reviews.html"
2766
+
2767
+ for src_name, dst_name in files_map.items():
2768
+ src = TEMPLATES_DIR / src_name
2769
+ if src.exists():
2770
+ # Читаем шаблон и заменяем плейсхолдеры
2771
+ with open(src, "r", encoding="utf-8") as f:
2772
+ html = f.read()
2773
+ html = _replace_html_placeholders(html, config)
2774
+ with open(tmpl_dir / dst_name, "w", encoding="utf-8") as f:
2775
+ f.write(html)
2776
+ else:
2777
+ print(" [WARN] Шаблон не найден: " + src_name)
2778
+
2779
+
2780
+ def generate_demo_data_script(config):
2781
+ """Генерирует скрипт для создания демо-данных."""
2782
+ statuses = config.get("statuses", ["Новая", "В работе", "Завершена"])
2783
+ statuses_repr = repr(statuses)
2784
+
2785
+ lines = [
2786
+ "#!/usr/bin/env python3",
2787
+ "# -*- coding: utf-8 -*-",
2788
+ '"""Скрипт создания демонстрационных данных."""',
2789
+ "import sys, os",
2790
+ "sys.path.insert(0, os.path.dirname(__file__))",
2791
+ "from app import app",
2792
+ "from models import db, User, Request",
2793
+ "from werkzeug.security import generate_password_hash",
2794
+ "import random",
2795
+ "from datetime import datetime, timedelta",
2796
+ "",
2797
+ "with app.app_context():",
2798
+ " db.create_all()",
2799
+ " statuses = " + statuses_repr,
2800
+ " for i in range(1, 8):",
2801
+ " login = 'user' + str(i)",
2802
+ " if not User.query.filter_by(login=login).first():",
2803
+ " u = User(",
2804
+ " login=login,",
2805
+ " password=generate_password_hash('Password1'),",
2806
+ " role='user',",
2807
+ " )",
2808
+ ]
2809
+
2810
+ if config.get("user_full_name"):
2811
+ lines.append(" u.full_name = 'Пользователь ' + str(i) + ' Тестовый'")
2812
+ if config.get("user_email"):
2813
+ lines.append(" u.email = 'user' + str(i) + '@example.com'")
2814
+ if config.get("user_phone"):
2815
+ lines.append(" u.phone = '7900000000' + str(i)")
2816
+
2817
+ lines += [
2818
+ " db.session.add(u)",
2819
+ " db.session.commit()",
2820
+ "",
2821
+ " users = User.query.filter_by(role='user').all()",
2822
+ " for i in range(1, 16):",
2823
+ " u = random.choice(users)",
2824
+ " r = Request(",
2825
+ " user_id=u.id,",
2826
+ " status=random.choice(statuses),",
2827
+ " created_at=datetime.utcnow() - timedelta(days=random.randint(0, 60)),",
2828
+ " )",
2829
+ ]
2830
+
2831
+ if config.get("req_service_name"):
2832
+ lines.append(" r.service_name = 'Услуга ' + str(i)")
2833
+ if config.get("req_description"):
2834
+ lines.append(" r.description = 'Описание тестовой заявки номер ' + str(i)")
2835
+
2836
+ lines += [
2837
+ " db.session.add(r)",
2838
+ " db.session.commit()",
2839
+ " print('Демо-данные созданы!')",
2840
+ ]
2841
+
2842
+ return "\n".join(lines)
2843
+
2844
+
2845
+ def generate_requirements():
2846
+ """Возвращает содержимое requirements.txt."""
2847
+ return (
2848
+ "Flask==3.0.3\n"
2849
+ "Flask-SQLAlchemy==3.1.1\n"
2850
+ "Werkzeug==3.0.3\n"
2851
+ "Flask-Session==0.8.0\n"
2852
+ )
2853
+
2854
+
2855
+ def generate_site(config, output_path):
2856
+ """
2857
+ Главная функция генерации сайта.
2858
+
2859
+ config — словарь с настройками (из cli.py)
2860
+ output_path — строка или Path, куда создать папку проекта
2861
+ """
2862
+ project_name = config.get("project_name", "mysite")
2863
+ dest = pathlib.Path(output_path) / project_name
2864
+ dest.mkdir(parents=True, exist_ok=True)
2865
+
2866
+ print("\n📁 Создаю проект в: " + str(dest))
2867
+
2868
+ # instance/ для SQLite
2869
+ (dest / "instance").mkdir(exist_ok=True)
2870
+
2871
+ # 1. models.py
2872
+ print(" ✔ models.py")
2873
+ with open(dest / "models.py", "w", encoding="utf-8") as f:
2874
+ f.write(build_models(config))
2875
+
2876
+ # 2. app.py
2877
+ print(" ✔ app.py")
2878
+ with open(dest / "app.py", "w", encoding="utf-8") as f:
2879
+ f.write(build_app(config))
2880
+
2881
+ # 3. HTML-шаблоны
2882
+ print(" ✔ templates/")
2883
+ build_html_templates(dest, config)
2884
+
2885
+ # 4. Статика
2886
+ print(" ✔ static/")
2887
+ copy_static_files(dest, config)
2888
+
2889
+ # 5. requirements.txt
2890
+ print(" ✔ requirements.txt")
2891
+ with open(dest / "requirements.txt", "w", encoding="utf-8") as f:
2892
+ f.write(generate_requirements())
2893
+
2894
+ # 6. Демо-данные
2895
+ if config.get("demo_data"):
2896
+ print(" ✔ create_demo_data.py")
2897
+ with open(dest / "create_demo_data.py", "w", encoding="utf-8") as f:
2898
+ f.write(generate_demo_data_script(config))
2899
+
2900
+ # 7. .gitignore
2901
+ with open(dest / ".gitignore", "w", encoding="utf-8") as f:
2902
+ f.write("instance/\n__pycache__/\n*.pyc\n.env\nstatic/uploads/\n")
2903
+
2904
+ print("\n✅ Готово! Проект создан: " + str(dest))
2905
+ print("\nЗапуск:")
2906
+ print(" cd " + str(dest))
2907
+ print(" pip install -r requirements.txt")
2908
+ print(" python app.py")