Merge pull request 'production' (#2) from production into main

Reviewed-on: http://127.0.0.1:30300/kultmost/avtopost.kultmost.ru/pulls/2
This commit is contained in:
vladislav 2026-08-27 15:50:34 +00:00
commit b023fd00d2
15 changed files with 1000 additions and 492 deletions

View File

@ -12,7 +12,8 @@
"outputs": [],
"source": [
"!pip install -r requirements.txt"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -72,7 +73,8 @@
"EOF\n",
"\n",
"chmod +x .git/hooks/pre-commit\n"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -120,7 +122,8 @@
" load_env('env')\n",
"except:\n",
" load_env('env_example')"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -132,7 +135,7 @@
},
{
"cell_type": "code",
"execution_count": 1,
"execution_count": null,
"id": "2809c42c",
"metadata": {},
"outputs": [],
@ -161,7 +164,8 @@
" f.write('\\n\\n'.join(app))\n",
"\n",
"build()"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -195,7 +199,8 @@
"kubectl -n container-registry create secret generic registry-basic-auth \\\n",
" --from-file=auth=/tmp/registry.htpasswd \\\n",
" --dry-run=client -o yaml | kubectl apply -f -\n"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -209,7 +214,8 @@
"outputs": [],
"source": [
"!kubectl create secret docker-registry regcred --docker-server=$REGISTRY_HOST --docker-username='user' --docker-password=$REGISTRY_PASS --docker-email=\"$(git config --global --get user.email)\""
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -224,7 +230,8 @@
"source": [
"!kubectl create namespace kultmost\n",
"!kubectl create secret docker-registry regcred -n kultmost --docker-server=$REGISTRY_HOST --docker-username='user' --docker-password=$REGISTRY_PASS --docker-email=\"$(git config --global --get user.email)\""
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -238,7 +245,8 @@
"outputs": [],
"source": [
"!docker login $REGISTRY_HOST -u 'user' -p $REGISTRY_PASS"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -260,7 +268,8 @@
"outputs": [],
"source": [
"!docker pull $REGISTRY_HOST/python:3.13-alpine"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -274,7 +283,8 @@
"outputs": [],
"source": [
"!docker pull $REGISTRY_HOST/python:3.13-slim"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -296,7 +306,8 @@
"outputs": [],
"source": [
"!docker build -t $REGISTRY_HOST/api-tg:latest tg/."
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -310,7 +321,8 @@
"outputs": [],
"source": [
"!docker push $REGISTRY_HOST/api-tg:latest"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -332,7 +344,8 @@
"outputs": [],
"source": [
"!docker build -t $REGISTRY_HOST/avtopost-api:latest ."
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -346,7 +359,8 @@
"outputs": [],
"source": [
"!docker image push $REGISTRY_HOST/avtopost-api:latest"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -360,7 +374,8 @@
"outputs": [],
"source": [
"!kubectl rollout restart deployments/kultmost-api -n kultmost"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -382,7 +397,8 @@
"outputs": [],
"source": [
"!kubectl rollout restart deployments/vk-celery -n kultmost"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -396,7 +412,8 @@
"outputs": [],
"source": [
"!kubectl rollout restart deployments/email-celery -n kultmost"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -421,7 +438,8 @@
"docker pull gitea/gitea:1.22\n",
"docker tag gitea/gitea:1.22 $REGISTRY_HOST/gitea/gitea:1.22\n",
"docker push $REGISTRY_HOST/gitea/gitea:1.22"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -445,7 +463,8 @@
"docker tag busybox:1.36 $REGISTRY_HOST/busybox:1.36\n",
"docker tag rabbitmq:3.11-management $REGISTRY_HOST/rabbitmq:3.11-management\n",
"docker tag alpine:3.14 $REGISTRY_HOST/alpine:3.14"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -464,7 +483,8 @@
"docker push $REGISTRY_HOST/busybox:1.36\n",
"docker push $REGISTRY_HOST/rabbitmq:3.11-management\n",
"docker push $REGISTRY_HOST/alpine:3.14"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -486,7 +506,8 @@
"outputs": [],
"source": [
"!docker build -t $REGISTRY_HOST/ssh-tunnel:2 tunnel/"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -500,7 +521,8 @@
"outputs": [],
"source": [
"!docker push $REGISTRY_HOST/ssh-tunnel:2"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -514,7 +536,8 @@
"outputs": [],
"source": [
"!kubectl create secret generic ssh-id-rsa --from-file=id_rsa=/home/user/.ssh/id_rsa -n kultmost"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -528,7 +551,8 @@
"outputs": [],
"source": [
"!kubectl apply -n kultmost -f k8s/stunnel.yaml"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -550,7 +574,8 @@
"outputs": [],
"source": [
"!envsubst < k8s/prod.yaml | kubectl apply -n kultmost -f -"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -564,7 +589,8 @@
"outputs": [],
"source": [
"!envsubst < k8s/kultmost.yaml | kubectl apply -n kultmost -f -"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -578,7 +604,8 @@
"outputs": [],
"source": [
"!envsubst < k8s/mq.yaml | kubectl apply -n kultmost -f -"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -592,7 +619,8 @@
"outputs": [],
"source": [
"!envsubst < k8s/gitea.yaml | kubectl apply -n gitea -f -"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -606,7 +634,8 @@
"outputs": [],
"source": [
"!kubectl get pod -n kultmost"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -631,7 +660,8 @@
"echo \"$INIT_SSH'bash -s' <<'SCRIPT'\"\n",
"echo \"kubectl create secret docker-registry regcred --docker-server=$REGISTRY_HOST --docker-username='user' --docker-password=$REGISTRY_PASS --docker-email=\\\"$(git config --global --get user.email)\\\" && clear && history -c && history -w\"\n",
"echo \"SCRIPT\""
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -654,7 +684,8 @@
"echo \"clear\"\n",
"echo \"history -c\"\n",
"echo \"history -w\""
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -684,7 +715,8 @@
"outputs": [],
"source": [
"!rsync -avz -ogp --chown=www-data:www-data ./static/ kultmost:/var/www/static/"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -698,7 +730,8 @@
"outputs": [],
"source": [
"!echo $(git branch --show-current)"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -712,7 +745,8 @@
"outputs": [],
"source": [
"!rsync -avh --exclude-from=.rsyncignore --exclude=.venv/ . kultmost:/app/$(git branch --show-current)/"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -750,7 +784,8 @@
"EOF\n",
"\n",
"ssh kultmost 'systemctl daemon-reload && systemctl enable --now production.service && systemctl status --no-pager -l production.service'\n"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -780,7 +815,8 @@
"EOF\n",
"\n",
"ssh kultmost 'systemctl daemon-reload && systemctl enable --now fastapi.service && systemctl status --no-pager -l fastapi.service'\n"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -818,7 +854,8 @@
"EOF\n",
"\n",
"ssh kultmost 'systemctl daemon-reload && systemctl enable --now staging.service && systemctl status --no-pager -l staging.service'\n"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -854,7 +891,8 @@
"[Install]\n",
"WantedBy=default.target\n",
"EOF"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -884,7 +922,8 @@
"EOF\n",
"\n",
"ssh kultmost 'systemctl daemon-reload && systemctl enable --now staging.service && systemctl status --no-pager -l staging.service'\n"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -906,7 +945,8 @@
"outputs": [],
"source": [
"!ssh kultmost 'systemctl restart production.service'"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -920,7 +960,8 @@
"outputs": [],
"source": [
"!ssh kultmost 'systemctl status production.service'"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -934,7 +975,8 @@
"outputs": [],
"source": [
"!ssh kultmost 'systemctl restart fastapi.service'"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -948,7 +990,8 @@
"outputs": [],
"source": [
"!ssh kultmost 'systemctl status fastapi.service'"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -986,7 +1029,8 @@
"EOF\n",
"\n",
"ssh kultmost 'systemctl daemon-reload && systemctl enable --now vk-celery.service && systemctl status --no-pager -l vk-celery.service'\n"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -1000,7 +1044,8 @@
"outputs": [],
"source": [
"!ssh kultmost 'systemctl restart vk-celery.service'"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -1014,7 +1059,8 @@
"outputs": [],
"source": [
"!ssh kultmost 'systemctl status vk-celery.service'"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -1044,7 +1090,8 @@
"EOF\n",
"\n",
"ssh kultmost 'systemctl daemon-reload && systemctl enable --now email-celery.service && systemctl status --no-pager -l email-celery.service'\n"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -1058,7 +1105,8 @@
"outputs": [],
"source": [
"!ssh kultmost 'systemctl restart email-celery.service'"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -1072,7 +1120,8 @@
"outputs": [],
"source": [
"!ssh kultmost 'systemctl status email-celery.service'"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -1094,7 +1143,8 @@
"outputs": [],
"source": [
"# !scp kultmost:/etc/nginx/sites-enabled/default nginx_conf "
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -1110,7 +1160,8 @@
"!scp nginx_conf kultmost:/tmp/default\n",
"!ssh kultmost 'sudo mv /tmp/default /etc/nginx/sites-enabled/default'\n",
"!ssh kultmost 'nginx -t'"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -1124,7 +1175,8 @@
"outputs": [],
"source": [
"!ssh kultmost 'nginx -s reload'"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -1149,7 +1201,8 @@
"sudo usermod -a -G microk8s $USER\n",
"newgrp microk8s\n",
"microk8s status"
]
],
"display_name": ".venv"
},
{
"cell_type": "code",
@ -1165,7 +1218,8 @@
"curl -LO https://cert-manager.io/public-keys/cert-manager-keyring-2021-09-20-1020CF3C033D4F35BAE1C19E1226061C665DF13E.gpg\n",
"helm install cert-manager oci://quay.io/jetstack/charts/cert-manager --version v1.20.2 --namespace cert-manager --create-namespace --verify --keyring ./cert-manager-keyring-2021-09-20-1020CF3C033D4F35BAE1C19E1226061C665DF13E.gpg --set crds.enabled=true\n",
"microk8s helm install cert-manager oci://quay.io/jetstack/charts/cert-manager --version v1.20.2 --namespace cert-manager --create-namespace --verify --keyring ./cert-manager-keyring-2021-09-20-1020CF3C033D4F35BAE1C19E1226061C665DF13E.gpg --set crds.enabled=true"
]
],
"display_name": ".venv"
},
{
"cell_type": "markdown",
@ -1186,6 +1240,16 @@
},
"outputs": [],
"source": [
"// MIT License\n",
"\n",
"// Copyright (c) 2026 vladislav\n",
"\n",
"// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n",
"\n",
"// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n",
"\n",
"// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n",
"\n",
"m1 = figma.currentPage.children[0]\n",
"HTML_TAGS = new Set(['a', 'abbr', 'address', 'area', 'article', 'aside', 'audio', 'b', 'base', 'bdi', 'bdo', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'cite', 'code', 'col', 'colgroup', 'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt', 'em', 'embed', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'link', 'main', 'map', 'mark', 'meta', 'meter', 'nav', 'noscript', 'object', 'ol', 'optgroup', 'option', 'output', 'p', 'param', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', 'select', 'small', 'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track', 'u', 'ul', 'var', 'video', 'wbr']);\n",
"\n",
@ -1386,7 +1450,8 @@
"\n",
"sss = await element_create(pc, tablet, mob)\n",
"sss.innerHTML"
]
],
"display_name": ".venv"
}
],
"metadata": {

View File

@ -17,56 +17,69 @@ async def send_delivery(
current_user: dict = Depends(get_current_user)
):
"""
Создаёт новый документ отправки с обновлёнными статусом и полями.
Массовая отправка: создаёт новый документ для каждого выбранного item.
"""
# Проверяем, что текущий пользователь — отправитель
if str(current_user["_id"]) != payload.sender:
raise HTTPException(status_code=403, detail="Не авторизован как отправитель")
results = []
errors = []
# Валидация delivery_id
try:
delivery_id = ObjectId(payload.item)
except Exception:
raise HTTPException(status_code=400, detail="Неверный delivery ID")
for entry in payload.items:
# Проверяем, что текущий пользователь — отправитель
if str(current_user["_id"]) != entry.sender:
errors.append({"item": entry.item, "error": "Не авторизован как отправитель"})
continue
update_data = {
"status": "Ожидает получения",
"delivery_type": payload.delivery_type,
"sent_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
# Валидация delivery_id
try:
delivery_id = ObjectId(entry.item)
except Exception:
errors.append({"item": entry.item, "error": "Неверный delivery ID"})
continue
update_data = {
"status": "Ожидает получения",
"delivery_type": payload.delivery_type,
"sent_at": datetime.utcnow(),
"updated_at": datetime.utcnow(),
}
# Добавляем трек только для СДЭК
if payload.delivery_type == "СДЭК" and payload.track:
update_data["track"] = payload.track
# Ищем существующую запись
existing = await delivery_collection.find_one(
{"_id": delivery_id, "sender._id": entry.sender},
{"_id": 0}
)
if not existing:
errors.append({"item": entry.item, "error": "Доставка не найдена или не для вас"})
continue
# Уведомление получателю
notify = await notify_in_profile(NotifyItem(
user_id=str(existing["receiver"]["_id"]),
type_item="delivery",
title="Доставка картин",
link="/profile/delivery",
text=f"{str(existing['sender']['username'])} — прислал вам картины."
))
new_doc = {**existing, **update_data, "notify": notify.upserted_id}
result = await delivery_collection.insert_one(new_doc)
if result.inserted_id:
# TODO: send email
results.append({
"item": entry.item,
"delivery_id": str(result.inserted_id)
})
return {
"status": "success" if not errors else "partial",
"processed": results,
"errors": errors
}
# Добавляем трек только для СДЭК (если передан)
if payload.delivery_type == "СДЭК" and payload.track:
update_data["track"] = payload.track
# Ищем существующую запись, чтобы создать новую на её основе
existing = await delivery_collection.find_one(
{"_id": delivery_id, "sender._id": payload.sender},
{"_id": 0}
)
if not existing:
raise HTTPException(status_code=404, detail="Доставка не найдена или не для вас")
# Собираем новый документ для вставки
notify = await notify_in_profile(NotifyItem(
user_id=str(existing['receiver']['_id']),
type_item='delivery',
title='Доставка картин',
link=f'/profile/delivery',
text=f'{str(existing['sender']['username'])} — прислал вам картины.'
))
new_doc = {**existing, **update_data, 'notify':notify.upserted_id}
# Вставляем новый документ
result = await delivery_collection.insert_one(new_doc)
if (iserted_id:=result.inserted_id):
pass
# send email
return {"status": "success", "delivery_id": str(iserted_id)}
@router.post("/accept")
async def accept_delivery(
@ -74,37 +87,59 @@ async def accept_delivery(
current_user: dict = Depends(get_current_user)
):
"""
Создаёт новый документ отправки со статусом 'Получено'.
Массовое получение: создаёт новый документ со статусом 'Получено' для каждого item.
"""
try:
delivery_id = ObjectId(payload.item)
except Exception:
raise HTTPException(status_code=400, detail="Неверный delivery ID")
results = []
errors = []
# Берём исходный документ, чтобы вставить новый
existing = await delivery_collection.find_one(
{"_id": delivery_id, "receiver._id": current_user["_id"]},
{"_id": 0}
)
for item_id in payload.items:
try:
delivery_id = ObjectId(item_id)
except Exception:
errors.append({"item": item_id, "error": "Неверный delivery ID"})
continue
if not existing:
raise HTTPException(status_code=404, detail="Доставка не найдена или не для вас")
notify = await notify_in_profile(NotifyItem(
user_id=str(existing['sender']['_id']),
type_item='delivery',
title='Доставка картин',
link=f'/profile/delivery',
text=f'Картина «{existing['title']}» получена — {str(existing['receiver']['username'])}.'
))
new_doc = {
**existing,
"status": "Получено",
"notify": notify.upserted_id,
"received_at": datetime.utcnow(),
"updated_at": datetime.utcnow(),
}
existing = await delivery_collection.find_one(
{"_id": delivery_id, "receiver._id": current_user["_id"]},
{"_id": 0}
)
if not existing:
errors.append({"item": item_id, "error": "Доставка не найдена или не для вас"})
continue
result = await delivery_collection.insert_one(new_doc)
# Уведомление отправителю
notify = await notify_in_profile(NotifyItem(
user_id=str(existing["sender"]["_id"]),
type_item="delivery",
title="Доставка картин",
link="/profile/delivery",
text=f"Картина «{existing['title']}» получена — {str(existing['receiver']['username'])}."
))
await notify_collection.update_one({'_id': existing['notify']}, {'$set': {'read': False}})
return {"status": "success", "delivery_id": str(result.inserted_id)}
new_doc = {
**existing,
"status": "Получено",
"notify": notify.upserted_id,
"received_at": datetime.utcnow(),
"updated_at": datetime.utcnow(),
}
result = await delivery_collection.insert_one(new_doc)
# Помечаем предыдущее уведомление как непрочитанное (если нужно)
if existing.get("notify"):
await notify_collection.update_one(
{"_id": existing["notify"]},
{"$set": {"read": False}}
)
results.append({
"item": item_id,
"delivery_id": str(result.inserted_id)
})
return {
"status": "success" if not errors else "partial",
"processed": results,
"errors": errors
}

View File

@ -557,7 +557,7 @@
"\n",
" # 1) If there's an exhibition that is going to be finished soon -> move deliveries to \"Возврат художнику\"\n",
" exhibition_finished = await exhibition_collection.find_one_and_update(\n",
" {**prod_filter, \"date_finish\": {\"$lt\": limit_dt}},\n",
" {**prod_filter, \"status\": 1, \"date_finish\": {\"$lt\": limit_dt}},\n",
" {\"$set\": {\"status\": 2}},\n",
" return_document=ReturnDocument.AFTER,\n",
" )\n",
@ -2903,7 +2903,7 @@
" competition = await competitions_col.find_one({'_id': ObjectId(competition_id)})\n",
" data = json.dumps(competition, cls=MongoJSONEncoder, ensure_ascii=False)\n",
" return templates.TemplateResponse(\n",
" request=request, name=\"form/competition_edit.html\", context={\"competition\": str(data), \"user\": user}\n",
" request=request, name=\"form/competition_edit.html\", context={\"competition\": str(data), \"competition_id\": competition_id, \"user\": user}\n",
" )\n"
],
"display_name": ".venv"
@ -4554,6 +4554,48 @@
"id": "6157e497",
"metadata": {},
"outputs": [],
"source": [
"# @app.get('/profile/delivery/{type_d}/{item_id}')\n",
"# async def profile_delivery_by_old(request: Request, type_d:str, item_id:str, user: Dict = Security(get_current_user)):\n",
"# cursor = delivery_collection.aggregate([\n",
"# {\"$match\": {\n",
"# \"$or\": [\n",
"# {\"sender._id\": user.get('_id')},\n",
"# {\"user._id\": user.get('_id')},\n",
"# {\"receiver._id\": user.get('_id')}\n",
"# ],\n",
"# f'{type_d}_id': ObjectId(item_id),\n",
"# 'prod': PROD,\n",
"# }},\n",
"# {\"$sort\": {\"_id\": -1}},\n",
"# {\"$group\": {\n",
"# \"_id\": \"$post_id\",\n",
"# \"doc\": {\"$first\": \"$$ROOT\"}\n",
"# }},\n",
"# {\"$replaceRoot\": {\"newRoot\": \"$doc\"}},\n",
"# {\"$sort\": {\"_id\": -1}},\n",
"# ])\n",
"\n",
"# data = [doc async for doc in cursor]\n",
"# data.sort(key=lambda doc: doc[\"_id\"].generation_time, reverse=True)\n",
"\n",
"# return templates.TemplateResponse(\n",
"# request=request,\n",
"# name=\"profile/delivery_old.html\",\n",
"# context={\n",
"# \"user\": user,\n",
"# \"data\": data,\n",
"# }\n",
"# )\n"
],
"display_name": ".venv"
},
{
"cell_type": "code",
"execution_count": null,
"id": "089ba597",
"metadata": {},
"outputs": [],
"source": [
"@app.get('/profile/delivery/competition/{competition_id}')\n",
"async def profile_delivery_by_competition(request: Request, competition_id:str, user: Dict = Security(get_current_user)):\n",
@ -4564,7 +4606,6 @@
" {\"user._id\": user.get('_id')},\n",
" {\"receiver._id\": user.get('_id')}\n",
" ],\n",
" 'competition_id': ObjectId(competition_id),\n",
" 'prod': PROD,\n",
" }},\n",
" {\"$sort\": {\"_id\": -1}},\n",
@ -4577,6 +4618,8 @@
" ])\n",
"\n",
" data = [doc async for doc in cursor]\n",
" data.sort(key=lambda doc: doc[\"_id\"].generation_time, reverse=True)\n",
"\n",
" return templates.TemplateResponse(\n",
" request=request,\n",
" name=\"profile/delivery_old.html\",\n",
@ -4591,7 +4634,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "d68edb20",
"id": "2148ef21",
"metadata": {},
"outputs": [],
"source": [
@ -4617,6 +4660,8 @@
" ])\n",
"\n",
" data = [doc async for doc in cursor]\n",
" data.sort(key=lambda doc: doc[\"_id\"].generation_time, reverse=True)\n",
"\n",
" return templates.TemplateResponse(\n",
" request=request,\n",
" name=\"profile/delivery_old.html\",\n",
@ -4624,7 +4669,7 @@
" \"user\": user,\n",
" \"data\": data,\n",
" }\n",
" )\n"
" )"
],
"display_name": ".venv"
},

44
main.py
View File

@ -345,7 +345,7 @@ async def cron_every_five_min(token: str = Security(get_token)):
# 1) If there's an exhibition that is going to be finished soon -> move deliveries to "Возврат художнику"
exhibition_finished = await exhibition_collection.find_one_and_update(
{**prod_filter, "date_finish": {"$lt": limit_dt}},
{**prod_filter, "status": 1, "date_finish": {"$lt": limit_dt}},
{"$set": {"status": 2}},
return_document=ReturnDocument.AFTER,
)
@ -2162,7 +2162,7 @@ async def form_(
competition = await competitions_col.find_one({'_id': ObjectId(competition_id)})
data = json.dumps(competition, cls=MongoJSONEncoder, ensure_ascii=False)
return templates.TemplateResponse(
request=request, name="form/competition_edit.html", context={"competition": str(data), "user": user}
request=request, name="form/competition_edit.html", context={"competition": str(data), "competition_id": competition_id, "user": user}
)
@ -3459,6 +3459,40 @@ async def profile_delivery_history_post(request: Request, post_id:str, user: Dic
)
# @app.get('/profile/delivery/{type_d}/{item_id}')
# async def profile_delivery_by_old(request: Request, type_d:str, item_id:str, user: Dict = Security(get_current_user)):
# cursor = delivery_collection.aggregate([
# {"$match": {
# "$or": [
# {"sender._id": user.get('_id')},
# {"user._id": user.get('_id')},
# {"receiver._id": user.get('_id')}
# ],
# f'{type_d}_id': ObjectId(item_id),
# 'prod': PROD,
# }},
# {"$sort": {"_id": -1}},
# {"$group": {
# "_id": "$post_id",
# "doc": {"$first": "$$ROOT"}
# }},
# {"$replaceRoot": {"newRoot": "$doc"}},
# {"$sort": {"_id": -1}},
# ])
# data = [doc async for doc in cursor]
# data.sort(key=lambda doc: doc["_id"].generation_time, reverse=True)
# return templates.TemplateResponse(
# request=request,
# name="profile/delivery_old.html",
# context={
# "user": user,
# "data": data,
# }
# )
@app.get('/profile/delivery/competition/{competition_id}')
async def profile_delivery_by_competition(request: Request, competition_id:str, user: Dict = Security(get_current_user)):
cursor = delivery_collection.aggregate([
@ -3468,7 +3502,6 @@ async def profile_delivery_by_competition(request: Request, competition_id:str,
{"user._id": user.get('_id')},
{"receiver._id": user.get('_id')}
],
'competition_id': ObjectId(competition_id),
'prod': PROD,
}},
{"$sort": {"_id": -1}},
@ -3481,6 +3514,8 @@ async def profile_delivery_by_competition(request: Request, competition_id:str,
])
data = [doc async for doc in cursor]
data.sort(key=lambda doc: doc["_id"].generation_time, reverse=True)
return templates.TemplateResponse(
request=request,
name="profile/delivery_old.html",
@ -3513,6 +3548,8 @@ async def profile_delivery_by_exhibition(request: Request, exhibition_id:str, us
])
data = [doc async for doc in cursor]
data.sort(key=lambda doc: doc["_id"].generation_time, reverse=True)
return templates.TemplateResponse(
request=request,
name="profile/delivery_old.html",
@ -3522,7 +3559,6 @@ async def profile_delivery_by_exhibition(request: Request, exhibition_id:str, us
}
)
@app.get(
"/profile/delivery",
response_class=HTMLResponse,

View File

@ -14,16 +14,20 @@ class NotifyItem(BaseModel):
type_item: str = "c"
class DeliverySendRequest(BaseModel):
class DeliverySendItem(BaseModel):
item: str
sender: str
too: str
item: str
class DeliverySendRequest(BaseModel):
items: List[DeliverySendItem]
delivery_type: Literal["СДЭК", "Самостоятельно"]
track: Optional[str] = Field(None, description="Трэк-номер для СДЭК")
class DeliveryAcceptRequest(BaseModel):
item: str
items: List[str]
class UserRegister(BaseModel):

View File

@ -712,6 +712,7 @@
" },{\n",
" \"$set\":{\n",
" \"sender\": user,\n",
" \"user\": user,\n",
" \"receiver\": display_user(exhibition_owner),\n",
" \"exhibition_id\": str(exhibition.get('_id')),\n",
" \"address\": exhibition.get('address'),\n",

View File

@ -541,6 +541,7 @@ async def exhibition_send_invitations(exhibition_id):
},{
"$set":{
"sender": user,
"user": user,
"receiver": display_user(exhibition_owner),
"exhibition_id": str(exhibition.get('_id')),
"address": exhibition.get('address'),

View File

@ -31,6 +31,7 @@
<span class="b_0jst1">{{ {-1:'Формируется',0:'Прием заявок',1:'Идет отбор',2:'Закончился'}[competition.get('status',-1)] }}</span>
</div>
{% endif %}
<span class="b_sdf">До {{ (competition.date_finish.strftime('%d.%m.%Y')) if competition.date_finish else ''}}</span>
<header class="c_m876y b_9xtz9">
<h3 class="c_hysjg b_8f3uh">{{ competition.title }}</h3>
</header>

View File

@ -1,7 +1,7 @@
{% import "competition/list.html" as competition %}
{% import "components/checkbox.html" as checkbox %}
{% macro section(user, competition) -%}
{% macro section(user, competition, competition_id) -%}
<form class="c_sbrxk">
<div class="c_dtjoh">
<h3>Выбор</h3>
@ -346,7 +346,7 @@ function bindLinkValidation({
<script>
document.addEventListener('DOMContentLoaded', () => {
const competition = JSON.parse({{competition|tojson}})
const competitionId = competition._id
const competitionId = `{{competition_id}}`
document.getElementById('title').value = competition.title;
document.getElementById('preview_title').textContent = competition.title;
@ -595,7 +595,7 @@ document.addEventListener('DOMContentLoaded', () => {
console.log('Отправка формы...');
try {
const res = await fetch(`/api/competition/${competitionId}`, {
const res = await fetch(`/api/competition/{{competition_id}}`, {
method: 'PUT',
body: formData
});
@ -895,5 +895,4 @@ input[type="number"]:focus::-webkit-inner-spin-button {display: none;}
.c_sbrxk{z-index: 1000;}
#preview_poster{width: 335px;}
</style>
{%- endmacro %}

View File

@ -14,6 +14,6 @@
{% block content %}
{{ header.section(user) }}
{{ form.section(user, competition) }}
{{ form.section(user, competition, competition_id) }}
{{ footer.section() }}
{% endblock %}

View File

@ -7,131 +7,418 @@
{% block title %}Где картины{% endblock %}
{% macro render_doc(doc) %}
<figure class="c_4b1hy b_uu2ub" data-exhibition_id="{{doc.get('exhibition_id')}}" data-delivery-id="{{doc._id}}" data-sender="{{doc.sender._id}}" data-receiver="{{doc.receiver._id}}">
<img class="b_1fvkt" src="/b/posts/{{doc.img}}">
<figcaption class="c_3g8gh b_pvw5a" data-id="{{doc._id}}">
{% if doc.status == 'Ожидает отправки' %}
<div class="b_thlt1">
<span class="b_5dr47">{{doc.status}}</span>
<figcaption class="c_3g8gh b_pvw5a" data-id="{{doc._id}}">
{% if doc.status == 'Ожидает отправки' %}
<div class="b_thlt1">
<span class="b_5dr47">{{doc.status}}</span>
</div>
{% elif doc.status == 'Получено' %}
<div class="b_thlt1">
<span class="b_5dr47">{{doc.status}}</span>
</div>
{% elif doc.status == 'Ожидает получения' %}
<div class="b_thlt1_2">
<span class="b_5dr47_2">{{doc.status}}</span>
</div>
{% else %}
<div class="b_thlt1_2">
<span class="b_5dr47_2">{{doc.status}}</span>
</div>
{% endif %}
<a class="b_6spaf" href="/profile/delivery/{{doc.post_id}}#main">Посмотреть историю</a>
<h2 class="c_9hvtd b_f5fb2">{{doc.title}}</h2>
{% if doc.type == 'exhibition' and doc.get('exhibition') %}
<p class="b_6spaf">{{doc.get('exhibition').title}}</p>
<div class="b_xz6em">
<img src="/a/{{doc.receiver._id}}" class="b_ob59y">
<div class="c_9hjdk b_ffruz">
<span class="b_qla2q">Организатор</span>
<span class="c_zm0m4 b_09est">{{doc.receiver.username}}</span>
</div>
{% elif doc.status == 'Получено' %}
<div class="b_thlt1">
<span class="b_5dr47">{{doc.status}}</span>
</div>
{% else %}
{% if doc.address %}
<p class="b_6spaf">Адрес: {{doc.address}}</p>
{% endif %}
{% if doc.back_address %}
<p class="b_6spaf">Адрес для возврата: {{doc.back_address}}</p>
{% endif %}
{% if doc.delivery_type %}
<p class="b_6spaf">Способ доставки: {{doc.delivery_type}}</p>
{% endif %}
{% if doc.track %}
<p class="b_6spaf">Трэк-номер: {{doc.track}}</p>
{% endif %}
{% if doc.sender %}
<div class="b_xz6em">
<img src="/a/{{doc.sender._id}}" class="b_ob59y">
<div class="c_9hjdk b_ffruz">
<span class="b_qla2q">Отправитель</span>
<span class="c_zm0m4 b_09est">{{doc.sender.username}}</span>
</div>
{% elif doc.status == 'Ожидает получения' %}
<div class="b_thlt1_2">
<span class="b_5dr47_2">{{doc.status}}</span>
</div>
{% endif %}
{% if doc.receiver %}
<div class="b_xz6em">
<img src="/a/{{doc.receiver._id}}" class="b_ob59y">
<div class="c_9hjdk b_ffruz">
<span class="b_qla2q">Получатель</span>
<span class="c_zm0m4 b_09est">{{doc.receiver.username}}</span>
<span class="b_qla2q">+7 *** *** {{ doc.receiver.phone[-4:] }}</span>
</div>
{% else %}
<div class="b_thlt1_2">
<span class="b_5dr47_2">{{doc.status}}</span>
</div>
{% endif %}
<a class="b_6spaf" href="/profile/delivery/{{doc.post_id}}#main">Посмотреть историю</a>
<h2 class="c_9hvtd b_f5fb2">{{doc.title}}</h2>
{% if doc.type == 'exhibition' and doc.get('exhibition') %}
<p class="b_6spaf">{{doc.get('exhibition').title}}</p>
<div class="b_xz6em">
<img src="/a/{{doc.receiver._id}}" class="b_ob59y">
<div class="c_9hjdk b_ffruz">
<span class="b_qla2q">Организатор</span>
<span class="c_zm0m4 b_09est">{{doc.receiver.username}}</span>
</div>
</div>
{% else %}
{% if doc.address %}
<p class="b_6spaf">Адрес: {{doc.address}}</p>
{% endif %}
{% if doc.back_address %}
<p class="b_6spaf">Адрес для возврата: {{doc.back_address}}</p>
{% endif %}
{% if doc.delivery_type %}
<p class="b_6spaf">Способ доставки: {{doc.delivery_type}}</p>
{% endif %}
{% if doc.track %}
<p class="b_6spaf">Трэк-номер: {{doc.track}}</p>
{% endif %}
{% if doc.sender %}
<div class="b_xz6em">
<img src="/a/{{doc.sender._id}}" class="b_ob59y">
<div class="c_9hjdk b_ffruz">
<span class="b_qla2q">Отправитель</span>
<span class="c_zm0m4 b_09est">{{doc.sender.username}}</span>
</div>
</div>
{% endif %}
{% if doc.receiver %}
<div class="b_xz6em">
<img src="/a/{{doc.receiver._id}}" class="b_ob59y">
<div class="c_9hjdk b_ffruz">
<span class="b_qla2q">Получатель</span>
<span class="c_zm0m4 b_09est">{{doc.receiver.username}}</span>
<span class="b_qla2q">+7 *** *** {{ doc.receiver.phone[-4:] }}</span>
</div>
</div>
{% endif %}
{% if doc.status in ['Ожидает отправки', 'Возврат художнику'] and user._id == doc.sender._id%}
<button class="b_wy3rt ba" onclick="btnSend(this, '{{doc._id}}', '{{doc.sender._id}}', '{{doc.receiver._id}}')">
<span class="b_bvl0d">Отправить</span>
</button>
{% endif %}
{% if doc.status == 'Ожидает получения' and user._id == doc.receiver._id%}
<button class="b_wy3rt ba" onclick="btnAccept(this, '{{doc._id}}')">
<span class="b_bvl0d">Получить</span>
</button>
{% endif %}
{% endif %}
<p class="b_6spaf">
{{ (doc._id.generation_time.strftime('%d.%m.%Y')) if doc._id else ''}}
<b>{{ '%02d' % ((doc._id.generation_time.hour + 3) % 24) if doc._id else '' }}:{{ '%02d' % doc._id.generation_time.minute if doc._id else '' }}</b>
</p>
</figcaption>
</div>
{% endif %}
{# Checkbox instead of per-item button #}
{% if doc.status in ['Ожидает отправки', 'Возврат художнику'] and user._id == doc.sender._id %}
<label class="offer-label delivery-checkbox-label">
<input type="checkbox"
class="offer-checkbox delivery-checkbox send-checkbox"
data-id="{{doc._id}}"
data-sender="{{doc.sender._id}}"
data-receiver="{{doc.receiver._id}}">
<span>Выбрать для отправки</span>
</label>
{% endif %}
{% if doc.status == 'Ожидает получения' and user._id == doc.receiver._id %}
<label class="offer-label delivery-checkbox-label">
<input type="checkbox"
class="offer-checkbox delivery-checkbox accept-checkbox"
data-id="{{doc._id}}">
<span>Выбрать для получения</span>
</label>
{% endif %}
{% endif %}
<p class="b_6spaf">
{{ (doc._id.generation_time.strftime('%d.%m.%Y')) if doc._id else ''}}
<b>{{ '%02d' % ((doc._id.generation_time.hour + 3) % 24) if doc._id else '' }}:{{ '%02d' % doc._id.generation_time.minute if doc._id else '' }}</b>
</p>
</figcaption>
</figure>
{% endmacro %}
{% block content %}
{{ header.section(user) }}
<section class="c_dg3k4">
<div class="c_gtoe9">
{{ profile_card.profile_card(user) }}
<div class="c_ru432">
{{ profile_nav.profile_nav(user, 'delivery') }}
<div class="c_tihtk" id="main">
{% set statuses = ['Ожидает отправки', 'Возврат художнику'] %}
{% if data | selectattr('status', 'in', statuses) | list | length > 0 %}
<article class="c_9fgdh b_ax8vz">
<div class="c_z3dpx b_73p10">
<header class="c_m876y b_9xtz9">
<h3 class="b_8f3uh">Настройки отправки</h3>
</header>
<p class="b_tfnz2">Можно менять перед каждой отправкой картины</p>
<div class="b_73p10">
<div class="c_aihm6 floating-group">
<select class="c_xhd5k" type="text" id="delivery_type" name="delivery_type" required>
<option>СДЭК</option>
<option>Самостоятельно</option>
</select>
<label for="delivery_type" class="c_z3ybu floating-label">Способ доставки</label>
</div>
<div class="c_aihm6 floating-group">
<input placeholder="..." type="text" id="track" name="track" class="c_xhd5k" required
data-toast="Поле Трэк-номер должно быть заполнено">
<label for="track" class="c_z3ybu floating-label">Трэк-номер</label>
</div>
</div>
</div>
</article>
{% endif %}
{% for doc in data if doc.status in statuses %}
{{ render_doc(doc) }}
{% endfor %}
{% for doc in data if doc.status not in statuses %}
{{ render_doc(doc) }}
{% endfor %}
<div class="c_gtoe9">
{{ profile_card.profile_card(user) }}
<div class="c_ru432">
{{ profile_nav.profile_nav(user, 'delivery') }}
<div class="c_tihtk" id="main">
{% set statuses = ['Ожидает отправки', 'Возврат художнику'] %}
{% if data | selectattr('status', 'in', statuses) | list | length > 0 %}
<article class="c_9fgdh b_ax8vz">
<div class="c_z3dpx b_73p10">
<header class="c_m876y b_9xtz9">
<h3 class="b_8f3uh">Настройки отправки</h3>
</header>
<p class="b_tfnz2">Можно менять перед каждой отправкой картины</p>
<div class="b_73p10">
<div class="c_aihm6 floating-group">
<select class="c_xhd5k" id="delivery_type" name="delivery_type" required>
<option>СДЭК</option>
<option>Самостоятельно</option>
</select>
<label for="delivery_type" class="c_z3ybu floating-label">Способ доставки</label>
</div>
<div class="c_aihm6 floating-group" id="track-group">
<input placeholder="..." type="text" id="track" name="track" class="c_xhd5k" required
data-toast="Поле Трэк-номер должно быть заполнено">
<label for="track" class="c_z3ybu floating-label">Трэк-номер</label>
</div>
</div>
</div>
</article>
{% endif %}
{% for doc in data %}
{{ render_doc(doc) }}
{% endfor %}
{# Sticky bottom action bar #}
<div class="st" style="z-index: 2000;min-height: 101px;padding: 25px;gap: 5px;">
<button class="b_wy3rt ba" id="btnSendSelected" style="display: none;" onclick="btnSendSelected()">
<span class="b_bvl0d">Отправить выбранные</span>
</button>
<button class="b_wy3rt ba" id="btnAcceptSelected" style="display: none;" onclick="btnAcceptSelected()">
<span class="b_bvl0d">Получить выбранные</span>
</button>
</div>
<script>
// ─────────────────────────────────────────────
// Helpers: collect selected IDs
// ─────────────────────────────────────────────
function getSelectedSendIds() {
return Array.from(document.querySelectorAll('.send-checkbox:checked')).map(cb => ({
id: cb.dataset.id,
sender: cb.dataset.sender,
receiver: cb.dataset.receiver
}));
}
function getSelectedAcceptIds() {
return Array.from(document.querySelectorAll('.accept-checkbox:checked')).map(cb => cb.dataset.id);
}
// ─────────────────────────────────────────────
// Sticky buttons visibility
// ─────────────────────────────────────────────
function updateStickyButtons() {
const sendCount = document.querySelectorAll('.send-checkbox:checked').length;
const acceptCount = document.querySelectorAll('.accept-checkbox:checked').length;
const sendBtn = document.getElementById('btnSendSelected');
const acceptBtn = document.getElementById('btnAcceptSelected');
if (sendBtn) sendBtn.style.display = sendCount > 0 ? 'inline-flex' : 'none';
if (acceptBtn) acceptBtn.style.display = acceptCount > 0 ? 'inline-flex' : 'none';
}
// ─────────────────────────────────────────────
// Show / hide track field depending on delivery_type
// ─────────────────────────────────────────────
function toggleTrackField() {
const deliveryType = document.getElementById('delivery_type');
const trackGroup = document.getElementById('track-group');
const trackInput = document.getElementById('track');
if (!deliveryType || !trackGroup || !trackInput) return;
if (deliveryType.value === 'Самостоятельно') {
trackGroup.style.display = 'none';
trackInput.removeAttribute('required');
trackInput.value = '';
} else {
trackGroup.style.display = '';
trackInput.setAttribute('required', '');
}
}
// ─────────────────────────────────────────────
// Validation helper (used by Send)
// ─────────────────────────────────────────────
function validateRequiredFields() {
const requiredInputs = document.querySelectorAll('input[required]');
const requiredTextareas = document.querySelectorAll('textarea[required]');
const requiredFields = [...requiredInputs, ...requiredTextareas];
let firstErrorField = null;
let hasError = false;
requiredFields.forEach(field => {
const group = field.closest('.floating-group');
if (!group) return;
let isEmpty = false;
if (field.type === 'file') {
isEmpty = !field.files || field.files.length === 0;
} else {
isEmpty = !field.value || !field.value.trim();
}
if (isEmpty) {
group.classList.add('error');
hasError = true;
showToast({ message: field.dataset.toast || 'Заполните обязательное поле', duration: 2500 });
if (!firstErrorField) firstErrorField = field;
} else {
group.classList.remove('error');
}
});
if (hasError && firstErrorField) {
firstErrorField.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => firstErrorField.focus(), 600);
return false;
}
return true;
}
// ─────────────────────────────────────────────
// Bulk Accept
// ─────────────────────────────────────────────
async function btnAcceptSelected() {
const ids = getSelectedAcceptIds();
if (!ids.length) return;
try {
const res = await fetch('/api/delivery/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: ids })
});
if (!res.ok) {
let errorMessage = 'Ошибка сохранения';
try {
const errorData = await res.json();
errorMessage = errorData.detail || errorMessage;
} catch (e) {
try {
const errorText = await res.text();
errorMessage = errorText || errorMessage;
} catch (e) {}
}
showToast({ message: errorMessage, duration: 2500 });
return;
}
const data = await res.json();
// Update UI for successfully processed items
(data.processed || []).forEach(({ item }) => {
const checkbox = document.querySelector(`.accept-checkbox[data-id="${item}"]`);
if (!checkbox) return;
const figcaption = checkbox.closest('figcaption');
const statusDiv = figcaption.querySelectorAll('div')[0];
const statusSpan = statusDiv.querySelector('span');
statusDiv.className = 'b_thlt1';
statusSpan.className = 'b_5dr47';
statusSpan.textContent = 'Получено';
checkbox.closest('label').style.display = 'none';
checkbox.checked = false;
});
if (data.errors?.length) {
showToast({ message: `Ошибки: ${data.errors.length}`, duration: 3000 });
}
} catch (error) {
console.error('Network error:', error);
showToast({ message: 'Не удалось подключиться к серверу', duration: 2500 });
}
updateStickyButtons();
}
// ─────────────────────────────────────────────
// Bulk Send
// ─────────────────────────────────────────────
async function btnSendSelected() {
const items = getSelectedSendIds();
if (!items.length) return;
// Validate required fields first
if (!validateRequiredFields()) return;
const delivery_type = document.getElementById('delivery_type')?.value || '';
const trackInput = document.getElementById('track');
let track = '';
if (delivery_type === 'СДЭК' && trackInput) {
track = trackInput.value.trim();
}
try {
const res = await fetch('/api/delivery/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: items.map(i => ({
item: i.id,
sender: i.sender,
too: i.receiver
})),
delivery_type,
track: track || null
})
});
if (!res.ok) {
let errorMessage = 'Ошибка сохранения';
try {
const errorData = await res.json();
errorMessage = errorData.detail || errorMessage;
} catch (e) {
try {
const errorText = await res.text();
errorMessage = errorText || errorMessage;
} catch (e) {}
}
showToast({ message: errorMessage, duration: 2500 });
return;
}
const data = await res.json();
// Update UI for successfully processed items
(data.processed || []).forEach(({ item }) => {
const checkbox = document.querySelector(`.send-checkbox[data-id="${item}"]`);
if (!checkbox) return;
const figcaption = checkbox.closest('figcaption');
const statusDiv = figcaption.querySelectorAll('div')[0];
const statusSpan = statusDiv.querySelector('span');
statusDiv.className = 'b_thlt1_2';
statusSpan.className = 'b_5dr47_2';
statusSpan.textContent = 'Ожидает получения';
checkbox.closest('label').style.display = 'none';
checkbox.checked = false;
});
if (data.errors?.length) {
showToast({ message: `Ошибки: ${data.errors.length}`, duration: 3000 });
}
} catch (error) {
console.error('Network error:', error);
showToast({ message: 'Не удалось подключиться к серверу', duration: 2500 });
}
updateStickyButtons();
}
// ─────────────────────────────────────────────
// Event listeners
// ─────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
// Initial state of track field
toggleTrackField();
// Change of delivery type
document.getElementById('delivery_type')?.addEventListener('change', toggleTrackField);
// Checkbox changes → update sticky buttons
document.addEventListener('change', (e) => {
if (e.target.classList.contains('delivery-checkbox')) {
updateStickyButtons();
}
});
});
</script>
<script defer>
function toggleTrackField() {
const deliveryType = document.getElementById('delivery_type');
const trackGroup = document.getElementById('track-group');
const trackInput = document.getElementById('track');
if (!deliveryType || !trackGroup || !trackInput) return;
if (deliveryType.value === 'Самостоятельно') {
trackGroup.style.display = 'none';
trackInput.removeAttribute('required');
trackInput.value = ''; // clear value when hidden
} else {
trackGroup.style.display = '';
trackInput.setAttribute('required', '');
}
}
// Run on page load
document.addEventListener('DOMContentLoaded', toggleTrackField);
// Run every time the select changes
document.getElementById('delivery_type')?.addEventListener('change', toggleTrackField);
const query = window.location.href.split('?')[1] || '';
let exhibition_id = null;
if (query) {
@ -175,132 +462,7 @@
deliveryType.addEventListener('change', toggleTrack);
toggleTrack();
</script>
<script>
async function btnAccept(btn, itemId){
const figcaption = btn.parentElement;
const statusDiv = figcaption.querySelectorAll('div')[0];
const statusSpan = statusDiv.querySelector('span')
try {
const res = await fetch('/api/delivery/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item: itemId,
})
});
if (!res.ok) {
let errorMessage = 'Ошибка сохранения';
try {
const errorData = await res.json();
errorMessage = errorData.detail || errorMessage;
} catch (e) {
try {
const errorText = await res.text();
errorMessage = errorText || errorMessage;
} catch (e) {
}
}
showToast({ message: errorMessage, duration: 2500 });
return;
}
statusDiv.className = 'b_thlt1';
statusSpan.className = 'b_5dr47';
statusSpan.textContent = 'Получено';
btn.style.display = 'none'
} catch (error) {
console.error('Network error:', error);
showToast({ message: 'Не удалось подключиться к серверу', duration: 2500 });
}
};
async function btnSend(btn, itemId, sender, too){
const figcaption = btn.parentElement;
const statusDiv = figcaption.querySelectorAll('div')[0];
const statusSpan = statusDiv.querySelector('span')
const delivery_type = document.getElementById('delivery_type').value;
const trackValue = document.getElementById('track');
let track = ''
if (delivery_type === 'СДЭК' && trackValue) {
track = trackValue.value
}
const requiredInputs = document.querySelectorAll('input[required]');
const requiredTextareas = document.querySelectorAll('textarea[required]');
const requiredFields = [...requiredInputs, ...requiredTextareas];
let firstErrorField = null;
let hasError = false;
requiredFields.forEach(field => {
const group = field.closest('.floating-group');
if (!group) return; // skip if no group wrapper
let isEmpty = false;
if (field.type === 'file') {
isEmpty = !field.files || field.files.length === 0;
} else {
isEmpty = !field.value || !field.value.trim();
}
if (isEmpty) {
group.classList.add('error');
hasError = true;
showToast({message: field.dataset.toast, duration:2500});
if (!firstErrorField) firstErrorField = field;
} else {
group.classList.remove('error');
}
});
if (hasError && firstErrorField) {
firstErrorField.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => firstErrorField.focus(), 600);
return;
}
try {
const res = await fetch('/api/delivery/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sender,
too,
item: itemId,
delivery_type,
track: track
})
});
if (!res.ok) {
let errorMessage = 'Ошибка сохранения';
try {
const errorData = await res.json();
errorMessage = errorData.detail || errorMessage;
} catch (e) {
// If response isn't JSON, try to get text
try {
const errorText = await res.text();
errorMessage = errorText || errorMessage;
} catch (e) {
// Fallback to generic error
}
}
showToast({ message: errorMessage, duration: 2500 });
return;
}
statusDiv.className = 'b_thlt1_2';
statusSpan.className = 'b_5dr47_2';
statusSpan.textContent = 'Ожидает получения';
btn.style.display = 'none'
} catch (error) {
console.error('Network error:', error);
showToast({ message: 'Не удалось подключиться к серверу', duration: 2500 });
}
};
</script>
</div>
</div>
</div>

View File

@ -71,16 +71,26 @@
</div>
</div>
{% endif %}
{% if doc.status in ['Ожидает отправки', 'Возврат художнику'] and user._id == doc.sender._id%}
<button class="b_wy3rt ba" onclick="btnSend(this, '{{doc._id}}', '{{doc.sender._id}}', '{{doc.receiver._id}}')">
<span class="b_bvl0d">Отправить</span>
</button>
{% endif %}
{% if doc.status == 'Ожидает получения' and user._id == doc.receiver._id%}
<button class="b_wy3rt ba" onclick="btnAccept(this, '{{doc._id}}')">
<span class="b_bvl0d">Получить</span>
</button>
{% endif %}
{# Checkbox instead of per-item button #}
{% if doc.status in ['Ожидает отправки', 'Возврат художнику'] and user._id == doc.sender._id %}
<label class="offer-label delivery-checkbox-label">
<input type="checkbox"
class="offer-checkbox delivery-checkbox send-checkbox"
data-id="{{doc._id}}"
data-sender="{{doc.sender._id}}"
data-receiver="{{doc.receiver._id}}">
<span>Выбрать для отправки</span>
</label>
{% endif %}
{% if doc.status == 'Ожидает получения' and user._id == doc.receiver._id %}
<label class="offer-label delivery-checkbox-label">
<input type="checkbox"
class="offer-checkbox delivery-checkbox accept-checkbox"
data-id="{{doc._id}}">
<span>Выбрать для получения</span>
</label>
{% endif %}
{% endif %}
<p class="b_6spaf">
{{ (doc._id.generation_time.strftime('%d.%m.%Y')) if doc._id else ''}}
@ -189,7 +199,7 @@
</select>
<label for="delivery_type" class="c_z3ybu floating-label">Способ доставки</label>
</div>
<div class="c_aihm6 floating-group">
<div class="c_aihm6 floating-group" id="track-group">
<input placeholder="..." type="text" id="track" name="track" class="c_xhd5k" required
data-toast="Поле Трэк-номер должно быть заполнено">
<label for="track" class="c_z3ybu floating-label">Трэк-номер</label>
@ -205,7 +215,40 @@
{% for doc in data if doc.status not in statuses %}
{{ render_doc(doc) }}
{% endfor %}
<script defer>
{# Sticky bottom action bar #}
<div class="st" style="z-index: 2000;min-height: 101px;padding: 25px;gap: 5px;">
<button class="b_wy3rt ba" id="btnSendSelected" style="display: none;" onclick="btnSendSelected()">
<span class="b_bvl0d">Отправить выбранные</span>
</button>
<button class="b_wy3rt ba" id="btnAcceptSelected" style="display: none;" onclick="btnAcceptSelected()">
<span class="b_bvl0d">Получить выбранные</span>
</button>
</div>
<script>
function toggleTrackField() {
const deliveryType = document.getElementById('delivery_type');
const trackGroup = document.getElementById('track-group');
const trackInput = document.getElementById('track');
if (!deliveryType || !trackGroup || !trackInput) return;
if (deliveryType.value === 'Самостоятельно') {
trackGroup.style.display = 'none';
trackInput.removeAttribute('required');
trackInput.value = ''; // clear value when hidden
} else {
trackGroup.style.display = '';
trackInput.setAttribute('required', '');
}
}
// Run on page load
document.addEventListener('DOMContentLoaded', toggleTrackField);
// Run every time the select changes
document.getElementById('delivery_type')?.addEventListener('change', toggleTrackField);
</script>
<script>
const query = window.location.href.split('?')[1] || '';
let exhibition_id = null;
if (query) {
@ -250,67 +293,69 @@
toggleTrack();
</script>
<script>
async function btnAccept(btn, itemId){
const figcaption = btn.parentElement;
const statusDiv = figcaption.querySelectorAll('div')[0];
const statusSpan = statusDiv.querySelector('span')
// ─────────────────────────────────────────────
// Helpers: collect selected IDs
// ─────────────────────────────────────────────
function getSelectedSendIds() {
return Array.from(document.querySelectorAll('.send-checkbox:checked')).map(cb => ({
id: cb.dataset.id,
sender: cb.dataset.sender,
receiver: cb.dataset.receiver
}));
}
try {
const res = await fetch('/api/delivery/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item: itemId,
})
});
function getSelectedAcceptIds() {
return Array.from(document.querySelectorAll('.accept-checkbox:checked')).map(cb => cb.dataset.id);
}
if (!res.ok) {
let errorMessage = 'Ошибка сохранения';
try {
const errorData = await res.json();
errorMessage = errorData.detail || errorMessage;
} catch (e) {
try {
const errorText = await res.text();
errorMessage = errorText || errorMessage;
} catch (e) {
}
}
showToast({ message: errorMessage, duration: 2500 });
return;
}
statusDiv.className = 'b_thlt1';
statusSpan.className = 'b_5dr47';
statusSpan.textContent = 'Получено';
btn.style.display = 'none'
} catch (error) {
console.error('Network error:', error);
showToast({ message: 'Не удалось подключиться к серверу', duration: 2500 });
// ─────────────────────────────────────────────
// Sticky buttons visibility
// ─────────────────────────────────────────────
function updateStickyButtons() {
const sendCount = document.querySelectorAll('.send-checkbox:checked').length;
const acceptCount = document.querySelectorAll('.accept-checkbox:checked').length;
const sendBtn = document.getElementById('btnSendSelected');
const acceptBtn = document.getElementById('btnAcceptSelected');
if (sendBtn) sendBtn.style.display = sendCount > 0 ? 'inline-flex' : 'none';
if (acceptBtn) acceptBtn.style.display = acceptCount > 0 ? 'inline-flex' : 'none';
}
// ─────────────────────────────────────────────
// Show / hide track field depending on delivery_type
// ─────────────────────────────────────────────
function toggleTrackField() {
const deliveryType = document.getElementById('delivery_type');
const trackGroup = document.getElementById('track-group');
const trackInput = document.getElementById('track');
if (!deliveryType || !trackGroup || !trackInput) return;
if (deliveryType.value === 'Самостоятельно') {
trackGroup.style.display = 'none';
trackInput.removeAttribute('required');
trackInput.value = '';
} else {
trackGroup.style.display = '';
trackInput.setAttribute('required', '');
}
};
async function btnSend(btn, itemId, sender, too){
const figcaption = btn.parentElement;
const statusDiv = figcaption.querySelectorAll('div')[0];
const statusSpan = statusDiv.querySelector('span')
}
const delivery_type = document.getElementById('delivery_type').value;
const trackValue = document.getElementById('track');
let track = ''
if (delivery_type === 'СДЭК' && trackValue) {
track = trackValue.value
}
const requiredInputs = document.querySelectorAll('input[required]');
// ─────────────────────────────────────────────
// Validation helper (used by Send)
// ─────────────────────────────────────────────
function validateRequiredFields() {
const requiredInputs = document.querySelectorAll('input[required]');
const requiredTextareas = document.querySelectorAll('textarea[required]');
const requiredFields = [...requiredInputs, ...requiredTextareas];
const requiredFields = [...requiredInputs, ...requiredTextareas];
let firstErrorField = null;
let hasError = false;
requiredFields.forEach(field => {
const group = field.closest('.floating-group');
if (!group) return; // skip if no group wrapper
if (!group) return;
let isEmpty = false;
if (field.type === 'file') {
@ -322,7 +367,7 @@ async function btnSend(btn, itemId, sender, too){
if (isEmpty) {
group.classList.add('error');
hasError = true;
showToast({message: field.dataset.toast, duration:2500});
showToast({ message: field.dataset.toast || 'Заполните обязательное поле', duration: 2500 });
if (!firstErrorField) firstErrorField = field;
} else {
group.classList.remove('error');
@ -332,48 +377,162 @@ async function btnSend(btn, itemId, sender, too){
if (hasError && firstErrorField) {
firstErrorField.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => firstErrorField.focus(), 600);
return;
return false;
}
return true;
}
// ─────────────────────────────────────────────
// Bulk Accept
// ─────────────────────────────────────────────
async function btnAcceptSelected() {
const ids = getSelectedAcceptIds();
if (!ids.length) return;
try {
const res = await fetch('/api/delivery/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: ids })
});
if (!res.ok) {
let errorMessage = 'Ошибка сохранения';
try {
const errorData = await res.json();
errorMessage = errorData.detail || errorMessage;
} catch (e) {
try {
const errorText = await res.text();
errorMessage = errorText || errorMessage;
} catch (e) {}
}
showToast({ message: errorMessage, duration: 2500 });
return;
}
const data = await res.json();
// Update UI for successfully processed items
(data.processed || []).forEach(({ item }) => {
const checkbox = document.querySelector(`.accept-checkbox[data-id="${item}"]`);
if (!checkbox) return;
const figcaption = checkbox.closest('figcaption');
const statusDiv = figcaption.querySelectorAll('div')[0];
const statusSpan = statusDiv.querySelector('span');
statusDiv.className = 'b_thlt1';
statusSpan.className = 'b_5dr47';
statusSpan.textContent = 'Получено';
checkbox.closest('label').style.display = 'none';
checkbox.checked = false;
});
if (data.errors?.length) {
showToast({ message: `Ошибки: ${data.errors.length}`, duration: 3000 });
}
} catch (error) {
console.error('Network error:', error);
showToast({ message: 'Не удалось подключиться к серверу', duration: 2500 });
}
updateStickyButtons();
}
// ─────────────────────────────────────────────
// Bulk Send
// ─────────────────────────────────────────────
async function btnSendSelected() {
const items = getSelectedSendIds();
if (!items.length) return;
// Validate required fields first
if (!validateRequiredFields()) return;
const delivery_type = document.getElementById('delivery_type')?.value || '';
const trackInput = document.getElementById('track');
let track = '';
if (delivery_type === 'СДЭК' && trackInput) {
track = trackInput.value.trim();
}
try {
const res = await fetch('/api/delivery/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sender,
too,
item: itemId,
delivery_type,
track: track
})
});
const res = await fetch('/api/delivery/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: items.map(i => ({
item: i.id,
sender: i.sender,
too: i.receiver
})),
delivery_type,
track: track || null
})
});
if (!res.ok) {
let errorMessage = 'Ошибка сохранения';
try {
const errorData = await res.json();
errorMessage = errorData.detail || errorMessage;
} catch (e) {
// If response isn't JSON, try to get text
try {
const errorText = await res.text();
errorMessage = errorText || errorMessage;
} catch (e) {
// Fallback to generic error
}
}
showToast({ message: errorMessage, duration: 2500 });
return;
if (!res.ok) {
let errorMessage = 'Ошибка сохранения';
try {
const errorData = await res.json();
errorMessage = errorData.detail || errorMessage;
} catch (e) {
try {
const errorText = await res.text();
errorMessage = errorText || errorMessage;
} catch (e) {}
}
statusDiv.className = 'b_thlt1_2';
showToast({ message: errorMessage, duration: 2500 });
return;
}
const data = await res.json();
// Update UI for successfully processed items
(data.processed || []).forEach(({ item }) => {
const checkbox = document.querySelector(`.send-checkbox[data-id="${item}"]`);
if (!checkbox) return;
const figcaption = checkbox.closest('figcaption');
const statusDiv = figcaption.querySelectorAll('div')[0];
const statusSpan = statusDiv.querySelector('span');
statusDiv.className = 'b_thlt1_2';
statusSpan.className = 'b_5dr47_2';
statusSpan.textContent = 'Ожидает получения';
btn.style.display = 'none'
checkbox.closest('label').style.display = 'none';
checkbox.checked = false;
});
if (data.errors?.length) {
showToast({ message: `Ошибки: ${data.errors.length}`, duration: 3000 });
}
} catch (error) {
console.error('Network error:', error);
showToast({ message: 'Не удалось подключиться к серверу', duration: 2500 });
console.error('Network error:', error);
showToast({ message: 'Не удалось подключиться к серверу', duration: 2500 });
}
};
updateStickyButtons();
}
// ─────────────────────────────────────────────
// Event listeners
// ─────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
// Initial state of track field
toggleTrackField();
// Change of delivery type
document.getElementById('delivery_type')?.addEventListener('change', toggleTrackField);
// Checkbox changes → update sticky buttons
document.addEventListener('change', (e) => {
if (e.target.classList.contains('delivery-checkbox')) {
updateStickyButtons();
}
});
});
</script>
{% endif %}
</div>

View File

@ -16,7 +16,7 @@
<div class="c_ru432">
{{ profile_nav.profile_nav(user, 'exhibitions') }}
<div class="c_3ihvi" id="main">
<a class="c_ctfod b_289i1" href="/profile/partner/exhibition/{{exhibition._id}}#main">
<a class="c_ctfod b_289i1 ba" href="/profile/partner/exhibition/{{exhibition._id}}#main">
<span class="c_d2l0b b_al8e7">Отобранные картины</span>
</a>
</div>
@ -49,7 +49,7 @@
<input type="text" id="filter-author" name="filter-author" class="c_xhd5k">
<label for="filter-author" class="c_z3ybu floating-label">Автор (ФИО)</label>
</div>
<button id="apply-filters" class="btn b">Применить фильтры</button>
<button id="apply-filters" class="btn b ba">Применить фильтры</button>
</div>
<article class="c_9fgdh b_ax8vz">
<!-- Poster -->

View File

@ -18,7 +18,7 @@
{{ profile_nav.profile_nav(user, 'exhibitions') }}
<div class="c_3ihvi" id="main">
{% if now < exhibition.date_finish %}
<a class="b_289i1" href="/profile/partner/exhibition/{{exhibition._id}}/catalog#main">
<a class="b_289i1 ba" href="/profile/partner/exhibition/{{exhibition._id}}/catalog#main">
<span class="c_d2l0b b_al8e7">Каталог картин</span>
</a>
{% endif %}
@ -29,7 +29,7 @@
%}
{% set uniq_count = ids | unique | list | length %}
{% if uniq_count > 0 %}
<button class="b_289i1" onclick="sendInvitations('{{exhibition._id}}')" href="/profile/partner/exhibition/{{exhibition._id}}/catalog#main">
<button class="b_289i1 ba" onclick="sendInvitations('{{exhibition._id}}')" href="/profile/partner/exhibition/{{exhibition._id}}/catalog#main">
<span class="c_d2l0b b_al8e7">Отправить приглашени{% if uniq_count > 1 %}я{% else %}е{% endif %}</span>
</button>
{% endif %}
@ -98,9 +98,10 @@
</button>
{% endif %}
{% if post.status == 'Ожидает получения' %}
<button class="b_289i1" onclick="btnAccept(this, '{{post.d_id}}')">
{# <button class="b_289i1" onclick="btnAccept(this, '{{post.d_id}}')"><span class="b_al8e7">Получить</span></button> #}
<a class="b_289i1" href="/profile/delivery/exhibition/{{exhibition._id}}#main">
<span class="b_al8e7">Получить</span>
</button>
</a>
{% endif %}
{% if post.status in ['Возврат художнику']%}
<a class="b_289i1" href="/profile/delivery/exhibition/{{exhibition._id}}#main">
@ -114,8 +115,9 @@
</div>
</div>
</section>
{#
<script>
async function btnAccept(btn, itemId){
async function btnAccept(btn, itemId){
const figcaption = btn.parentElement;
const statusDiv = figcaption.querySelectorAll('div')[0];
const statusSpan = statusDiv.querySelector('span')
@ -153,6 +155,9 @@ async function btnAccept(btn, itemId){
showToast({ message: 'Не удалось подключиться к серверу', duration: 2500 });
}
};
</script>
#}
<script>
async function sendInvitations(exhibition_id) {
const data = new FormData();
data.set('exhibition_id', exhibition_id);

View File

@ -1,5 +0,0 @@
FROM registry.spiritofweb.ru/alpine:3.14
RUN apk --no-cache add openssh-client bash autossh sshpass
CMD []