From fe497b5132b67a418503d1b89246ab4c57d61d28f3aec675e307a959fe9ab43e Mon Sep 17 00:00:00 2001 From: vladislav Date: Mon, 14 Sep 2026 06:50:05 -0400 Subject: [PATCH 01/16] gnupg --- email_task.ipynb | 274 +++++++++++++++++++++++++++++++----- email_task.py | 224 ++++++++++++++++++++++++++--- requirements.txt | 1 + templates/profile/form.html | 8 ++ 4 files changed, 452 insertions(+), 55 deletions(-) diff --git a/email_task.ipynb b/email_task.ipynb index d10e5d1..8397125 100644 --- a/email_task.ipynb +++ b/email_task.ipynb @@ -38,7 +38,8 @@ " load_env('env')\n", "except:\n", " load_env('env_example')" - ] + ], + "display_name": ".venv" }, { "cell_type": "code", @@ -48,17 +49,33 @@ "outputs": [], "source": [ "import smtplib\n", + "import os\n", + "from email.mime.application import MIMEApplication\n", "from email.mime.multipart import MIMEMultipart\n", "from email.mime.text import MIMEText\n", + "from email.utils import formatdate, make_msgid\n", + "from typing import Optional\n", + "import gnupg\n", "from celery import Celery\n", "from config import *\n", "from db import *\n", "from templates import templates\n", "import logging\n", - "from typing import Optional\n", - "import asyncio \n", - "from email.utils import formatdate, make_msgid\n" - ] + "import asyncio \n" + ], + "display_name": ".venv" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ea1066a5", + "metadata": {}, + "outputs": [], + "source": [ + "def build_unsubscribe_url(email: str, token: str, base_url: str) -> str:\n", + " return f\"{base_url}/api/v1/email/unsubscribe?token={token}&email={email}\"\n" + ], + "display_name": ".venv" }, { "cell_type": "code", @@ -67,37 +84,211 @@ "metadata": {}, "outputs": [], "source": [ - "def build_unsubscribe_url(email: str, token: str, base_url: str) -> str:\n", - " return f\"{base_url}/api/v1/email/unsubscribe?token={token}&email={email}\"\n", + "GPG_HOME = os.getenv(\"GPG_HOME\", \"/var/lib/app/.gnupg\")\n", + "GPG_BINARY = os.getenv(\"GPG_BINARY\", \"gpg\")\n", + "\n", + "gpg = gnupg.GPG(\n", + " gnupghome=GPG_HOME,\n", + " gpgbinary=GPG_BINARY,\n", + ")\n", + "\n", + "\n", + "def build_unsubscribe_url(\n", + " to_email: str,\n", + " user_id: Optional[int],\n", + " domain: str,\n", + ") -> str:\n", + " \"\"\"\n", + " Replace this implementation with your real unsubscribe URL builder.\n", + " Avoid putting a raw email address in the URL in production.\n", + " \"\"\"\n", + " from urllib.parse import quote\n", + "\n", + " return f\"https://{domain}/unsubscribe?email={quote(to_email)}\"\n", + "\n", + "\n", + "def import_public_key(public_key: str) -> str:\n", + " \"\"\"\n", + " Import an ASCII-armored public key and return its fingerprint.\n", + "\n", + " The database value should contain the complete public key, for example:\n", + "\n", + " -----BEGIN PGP PUBLIC KEY BLOCK-----\n", + " ...\n", + " -----END PGP PUBLIC KEY BLOCK-----\n", + " \"\"\"\n", + " result = gpg.import_keys(public_key)\n", + "\n", + " if not result.fingerprints:\n", + " raise RuntimeError(\n", + " f\"Unable to import the recipient's public key: {result}\"\n", + " )\n", + "\n", + " # Use the imported fingerprint rather than the email address. The email\n", + " # address in the key's UID may not exactly match the recipient address.\n", + " return result.fingerprints[0]\n", + "\n", + "\n", + "def encrypt_mime_message(\n", + " message,\n", + " recipient_fingerprint: str,\n", + "):\n", + " \"\"\"\n", + " Encrypt an already-built MIME message and return a PGP/MIME message.\n", + " \"\"\"\n", + "\n", + " # Serialize the original MIME message before encryption.\n", + " plaintext = message.as_bytes()\n", + "\n", + " # Binary OpenPGP data is appropriate for application/octet-stream.\n", + " encrypted = gpg.encrypt(\n", + " plaintext,\n", + " recipients=[recipient_fingerprint],\n", + " armor=False,\n", + " always_trust=False,\n", + " )\n", + "\n", + " if not encrypted.ok:\n", + " raise RuntimeError(\n", + " f\"GPG encryption failed: {encrypted.status}; \"\n", + " f\"stderr: {encrypted.stderr}\"\n", + " )\n", + "\n", + " encrypted_bytes = encrypted.data\n", + "\n", + " if not encrypted_bytes:\n", + " raise RuntimeError(\"GPG returned an empty encrypted message\")\n", + "\n", + " encrypted_message = MIMEMultipart(\n", + " \"encrypted\",\n", + " protocol=\"application/pgp-encrypted\",\n", + " )\n", + "\n", + " # Copy normal mail headers. Content-Type and MIME-Version are generated\n", + " # automatically for the new multipart/encrypted message.\n", + " for header in (\n", + " \"From\",\n", + " \"To\",\n", + " \"Subject\",\n", + " \"Message-ID\",\n", + " \"Date\",\n", + " \"X-Priority\",\n", + " \"Importance\",\n", + " \"X-Auto-Response-Suppress\",\n", + " \"List-Unsubscribe\",\n", + " \"List-Unsubscribe-Post\",\n", + " ):\n", + " value = message.get(header)\n", + " if value is not None:\n", + " encrypted_message[header] = value\n", + "\n", + " # Required PGP/MIME version part.\n", + " version_part = MIMEApplication(\n", + " b\"Version: 1\\r\\n\",\n", + " _subtype=\"pgp-encrypted\",\n", + " )\n", + " version_part.add_header(\n", + " \"Content-Description\",\n", + " \"PGP/MIME version identification\",\n", + " )\n", + " encrypted_message.attach(version_part)\n", + "\n", + " # Encrypted MIME payload.\n", + " encrypted_part = MIMEApplication(\n", + " encrypted_bytes,\n", + " _subtype=\"octet-stream\",\n", + " )\n", + " encrypted_part.add_header(\n", + " \"Content-Description\",\n", + " \"OpenPGP encrypted message\",\n", + " )\n", + " encrypted_part.add_header(\n", + " \"Content-Disposition\",\n", + " \"inline\",\n", + " filename=\"encrypted.asc\",\n", + " )\n", + " encrypted_message.attach(encrypted_part)\n", + "\n", + " return encrypted_message\n", "\n", "\n", "async def create_email_message(\n", " to_email: str,\n", " subject: str,\n", " html_content: str,\n", - " user_id: int = None,\n", + " user_id: Optional[int] = None,\n", "):\n", + " \"\"\"\n", + " Create an HTML email.\n", + "\n", + " If the recipient has a public GPG key in the database, the complete\n", + " MIME message is encrypted using PGP/MIME.\n", + "\n", + " Expected database structure:\n", + "\n", + " {\n", + " \"email\": \"recipient@example.com\",\n", + " \"gnupg\": \"-----BEGIN PGP PUBLIC KEY BLOCK-----\\\\n...\"\n", + " }\n", + " \"\"\"\n", + "\n", " msg = MIMEMultipart(\"alternative\")\n", - " \n", - " msg['From'] = f\"{FROM_NAME} <{FROM_EMAIL}>\"\n", - " msg['To'] = to_email\n", - " msg['Subject'] = subject\n", - " msg['Message-ID'] = make_msgid(domain=FROM_EMAIL.split('@')[1])\n", - " msg['Date'] = formatdate(localtime=True)\n", - " msg['X-Priority'] = \"3\"\n", - " msg['Importance'] = \"normal\"\n", - " msg['X-Auto-Response-Suppress'] = \"All\"\n", - " \n", - " # token = await create_unsubscribe_token(to_email, user_id)\n", - " unsubscribe_url = build_unsubscribe_url(to_email, None, FULL_DOMAIN)\n", - " if to_email != FROM_EMAIL:\n", - " msg['List-Unsubscribe'] = f\"<{unsubscribe_url}>\"\n", - " msg['List-Unsubscribe-Post'] = \"List-Unsubscribe=One-Click\"\n", - " \n", - " msg.attach(MIMEText(html_content, 'html'))\n", - " \n", - " return msg\n" - ] + "\n", + " msg[\"From\"] = f\"{FROM_NAME} <{FROM_EMAIL}>\"\n", + " msg[\"To\"] = to_email\n", + " msg[\"Subject\"] = subject\n", + " msg[\"Message-ID\"] = make_msgid(\n", + " domain=FROM_EMAIL.rsplit(\"@\", 1)[1],\n", + " )\n", + " msg[\"Date\"] = formatdate(localtime=True)\n", + " msg[\"X-Priority\"] = \"3\"\n", + " msg[\"Importance\"] = \"normal\"\n", + " msg[\"X-Auto-Response-Suppress\"] = \"All\"\n", + "\n", + " # Do not send unsubscribe headers to yourself.\n", + " if to_email.lower() != FROM_EMAIL.lower():\n", + " unsubscribe_url = build_unsubscribe_url(\n", + " to_email=to_email,\n", + " user_id=user_id,\n", + " domain=FULL_DOMAIN,\n", + " )\n", + "\n", + " msg[\"List-Unsubscribe\"] = f\"<{unsubscribe_url}>\"\n", + " msg[\"List-Unsubscribe-Post\"] = \"List-Unsubscribe=One-Click\"\n", + "\n", + " msg.attach(\n", + " MIMEText(\n", + " html_content,\n", + " \"html\",\n", + " \"utf-8\",\n", + " )\n", + " )\n", + "\n", + " # Fetch the recipient's public key.\n", + " user = await users_collection.find_one(\n", + " {\"email\": to_email},\n", + " {\"gnupg\": 1},\n", + " )\n", + "\n", + " public_key = user.get(\"gnupg\") if user else None\n", + "\n", + " if not public_key:\n", + " # Return a normal unencrypted email when no key is available.\n", + " return msg\n", + "\n", + " if not isinstance(public_key, str):\n", + " raise TypeError(\"The gnupg database field must be a string\")\n", + "\n", + " try:\n", + " fingerprint = import_public_key(public_key)\n", + " return encrypt_mime_message(msg, fingerprint)\n", + "\n", + " except Exception:\n", + " # Encryption is mandatory whenever a key exists.\n", + " # Do not silently send the message unencrypted.\n", + " raise\n" + ], + "display_name": ".venv" }, { "cell_type": "code", @@ -140,7 +331,8 @@ " f\"Subject: {subject}, Error: {str(e)}\"\n", " )\n", " return False\n" - ] + ], + "display_name": ".venv" }, { "cell_type": "code", @@ -150,7 +342,8 @@ "outputs": [], "source": [ "celery_app_email = Celery('emails', broker=f'{redis_uri}2', backend=f'{redis_uri}2', task_track_started=True)\n" - ] + ], + "display_name": ".venv" }, { "cell_type": "code", @@ -189,7 +382,8 @@ " f\"Ошибка при отправке email на {to_email} (попытка {self.request.retries + 1}): {exc}\"\n", " )\n", " raise self.retry(exc=exc)\n" - ] + ], + "display_name": ".venv" }, { "cell_type": "markdown", @@ -199,6 +393,18 @@ "## Run" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "3aeae7e8", + "metadata": {}, + "outputs": [], + "source": [ + "!mkdir -p /var/lib/app/.gnupg\n", + "!chmod 700 /var/lib/app/.gnupg" + ], + "display_name": ".venv" + }, { "cell_type": "code", "execution_count": null, @@ -228,7 +434,8 @@ "\n", " with open(f'{filename_without_extension}.py', 'w') as f:\n", " f.write('\\n\\n'.join(app))\n" - ] + ], + "display_name": ".venv" }, { "cell_type": "code", @@ -238,7 +445,8 @@ "outputs": [], "source": [ "!celery -A email_task.celery_app_email worker --loglevel=info" - ] + ], + "display_name": ".venv" } ], "metadata": { diff --git a/email_task.py b/email_task.py index 2299001..cdd7551 100644 --- a/email_task.py +++ b/email_task.py @@ -32,48 +32,228 @@ except: load_env('env_example') import smtplib +import os +from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from email.utils import formatdate, make_msgid +from typing import Optional +import gnupg from celery import Celery from config import * from db import * from templates import templates import logging -from typing import Optional import asyncio -from email.utils import formatdate, make_msgid def build_unsubscribe_url(email: str, token: str, base_url: str) -> str: return f"{base_url}/api/v1/email/unsubscribe?token={token}&email={email}" +GPG_HOME = os.getenv("GPG_HOME", "/var/lib/app/.gnupg") +GPG_BINARY = os.getenv("GPG_BINARY", "gpg") + +gpg = gnupg.GPG( + gnupghome=GPG_HOME, + gpgbinary=GPG_BINARY, +) + + +def build_unsubscribe_url( + to_email: str, + user_id: Optional[int], + domain: str, +) -> str: + """ + Replace this implementation with your real unsubscribe URL builder. + Avoid putting a raw email address in the URL in production. + """ + from urllib.parse import quote + + return f"https://{domain}/unsubscribe?email={quote(to_email)}" + + +def import_public_key(public_key: str) -> str: + """ + Import an ASCII-armored public key and return its fingerprint. + + The database value should contain the complete public key, for example: + + -----BEGIN PGP PUBLIC KEY BLOCK----- + ... + -----END PGP PUBLIC KEY BLOCK----- + """ + result = gpg.import_keys(public_key) + + if not result.fingerprints: + raise RuntimeError( + f"Unable to import the recipient's public key: {result}" + ) + + # Use the imported fingerprint rather than the email address. The email + # address in the key's UID may not exactly match the recipient address. + return result.fingerprints[0] + + +def encrypt_mime_message( + message, + recipient_fingerprint: str, +): + """ + Encrypt an already-built MIME message and return a PGP/MIME message. + """ + + # Serialize the original MIME message before encryption. + plaintext = message.as_bytes() + + # Binary OpenPGP data is appropriate for application/octet-stream. + encrypted = gpg.encrypt( + plaintext, + recipients=[recipient_fingerprint], + armor=False, + always_trust=False, + ) + + if not encrypted.ok: + raise RuntimeError( + f"GPG encryption failed: {encrypted.status}; " + f"stderr: {encrypted.stderr}" + ) + + encrypted_bytes = encrypted.data + + if not encrypted_bytes: + raise RuntimeError("GPG returned an empty encrypted message") + + encrypted_message = MIMEMultipart( + "encrypted", + protocol="application/pgp-encrypted", + ) + + # Copy normal mail headers. Content-Type and MIME-Version are generated + # automatically for the new multipart/encrypted message. + for header in ( + "From", + "To", + "Subject", + "Message-ID", + "Date", + "X-Priority", + "Importance", + "X-Auto-Response-Suppress", + "List-Unsubscribe", + "List-Unsubscribe-Post", + ): + value = message.get(header) + if value is not None: + encrypted_message[header] = value + + # Required PGP/MIME version part. + version_part = MIMEApplication( + b"Version: 1\r\n", + _subtype="pgp-encrypted", + ) + version_part.add_header( + "Content-Description", + "PGP/MIME version identification", + ) + encrypted_message.attach(version_part) + + # Encrypted MIME payload. + encrypted_part = MIMEApplication( + encrypted_bytes, + _subtype="octet-stream", + ) + encrypted_part.add_header( + "Content-Description", + "OpenPGP encrypted message", + ) + encrypted_part.add_header( + "Content-Disposition", + "inline", + filename="encrypted.asc", + ) + encrypted_message.attach(encrypted_part) + + return encrypted_message + + async def create_email_message( to_email: str, subject: str, html_content: str, - user_id: int = None, + user_id: Optional[int] = None, ): + """ + Create an HTML email. + + If the recipient has a public GPG key in the database, the complete + MIME message is encrypted using PGP/MIME. + + Expected database structure: + + { + "email": "recipient@example.com", + "gnupg": "-----BEGIN PGP PUBLIC KEY BLOCK-----\\n..." + } + """ + msg = MIMEMultipart("alternative") - - msg['From'] = f"{FROM_NAME} <{FROM_EMAIL}>" - msg['To'] = to_email - msg['Subject'] = subject - msg['Message-ID'] = make_msgid(domain=FROM_EMAIL.split('@')[1]) - msg['Date'] = formatdate(localtime=True) - msg['X-Priority'] = "3" - msg['Importance'] = "normal" - msg['X-Auto-Response-Suppress'] = "All" - - # token = await create_unsubscribe_token(to_email, user_id) - unsubscribe_url = build_unsubscribe_url(to_email, None, FULL_DOMAIN) - if to_email != FROM_EMAIL: - msg['List-Unsubscribe'] = f"<{unsubscribe_url}>" - msg['List-Unsubscribe-Post'] = "List-Unsubscribe=One-Click" - - msg.attach(MIMEText(html_content, 'html')) - - return msg + + msg["From"] = f"{FROM_NAME} <{FROM_EMAIL}>" + msg["To"] = to_email + msg["Subject"] = subject + msg["Message-ID"] = make_msgid( + domain=FROM_EMAIL.rsplit("@", 1)[1], + ) + msg["Date"] = formatdate(localtime=True) + msg["X-Priority"] = "3" + msg["Importance"] = "normal" + msg["X-Auto-Response-Suppress"] = "All" + + # Do not send unsubscribe headers to yourself. + if to_email.lower() != FROM_EMAIL.lower(): + unsubscribe_url = build_unsubscribe_url( + to_email=to_email, + user_id=user_id, + domain=FULL_DOMAIN, + ) + + msg["List-Unsubscribe"] = f"<{unsubscribe_url}>" + msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click" + + msg.attach( + MIMEText( + html_content, + "html", + "utf-8", + ) + ) + + # Fetch the recipient's public key. + user = await users_collection.find_one( + {"email": to_email}, + {"gnupg": 1}, + ) + + public_key = user.get("gnupg") if user else None + + if not public_key: + # Return a normal unencrypted email when no key is available. + return msg + + if not isinstance(public_key, str): + raise TypeError("The gnupg database field must be a string") + + try: + fingerprint = import_public_key(public_key) + return encrypt_mime_message(msg, fingerprint) + + except Exception: + # Encryption is mandatory whenever a key exists. + # Do not silently send the message unencrypted. + raise def connect(email_server, email_port): diff --git a/requirements.txt b/requirements.txt index 7a8d3eb..0ba4337 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,3 +38,4 @@ celery[redis]==5.6.3 pika==1.3.2 pillow==12.1.1 pytz +python-gnupg diff --git a/templates/profile/form.html b/templates/profile/form.html index 6712f42..ffd1aca 100644 --- a/templates/profile/form.html +++ b/templates/profile/form.html @@ -63,6 +63,12 @@ +
+ + + Самозащита электронной почты +
+
@@ -222,6 +228,7 @@ document.getElementById('living_place').value = `{% if user.get('living_place') document.getElementById('social_media').value = `{% if user.get('social_media') is not none %}{{user.get('social_media','') }}{% endif %}` document.getElementById('marketplaces').value = `{% if user.get('marketplaces') is not none %}{{user.get('marketplaces','') }}{% endif %}` document.getElementById('email').value = `{% if user.get('email') is not none %}{{user.get('email','') }}{% endif %}` +document.getElementById('gnupg').value = `{% if user.get('gnupg') is not none %}{{user.get('gnupg','') }}{% endif %}` document.getElementById('phone').value = `{% if user.get('phone') is not none %}{{user.get('phone','') }}{% endif %}` document.getElementById('messengers').value = `{% if user.get('messengers') is not none %}{{user.get('messengers','') }}{% endif %}` document.getElementById('specialization').value = `{% if user.get('specialization') is not none %}{{user.get('specialization','') }}{% endif %}` @@ -270,6 +277,7 @@ document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById( 'social_media': document.getElementById('social_media').value, 'marketplaces': document.getElementById('marketplaces').value, 'email': document.getElementById('email').value, + 'gnupg': document.getElementById('gnupg').value, 'phone': document.getElementById('phone').value, 'messengers': document.getElementById('messengers').value, 'specialization': document.getElementById('specialization').value, From 1d2ee61f5ffb9da600486b3b7ae7c310d12ce32ebb55aca4a7440617a6211d8d Mon Sep 17 00:00:00 2001 From: vladislav Date: Mon, 14 Sep 2026 06:50:17 -0400 Subject: [PATCH 02/16] fix --- templates/profile/partner/exhibition catalog.html | 6 ++++-- templates/profile/partner/exhibition.html | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/templates/profile/partner/exhibition catalog.html b/templates/profile/partner/exhibition catalog.html index 618ae83..3777d5e 100644 --- a/templates/profile/partner/exhibition catalog.html +++ b/templates/profile/partner/exhibition catalog.html @@ -6,7 +6,7 @@ {% import "competition/list.html" as competition %} {% import "components/label.html" as label %} -{% block title %}Каталог картин{% endblock %} +{% block title %}Каталог картин | Выставка {{exhibition.title}}{% endblock %} {% block content %} {{ header.section(user) }} @@ -17,7 +17,7 @@ {{ profile_nav.profile_nav(user, 'exhibitions') }}
@@ -476,6 +476,8 @@ genre = genres.map(g => { // Обновляем счётчик const lenEl = document.getElementById('lenPosts'); if (lenEl) lenEl.textContent = exhibition.posts.length; + const lenEl2 = document.getElementById('lenPosts2'); + if (lenEl2) lenEl2.textContent = exhibition.posts.length; } else { el.checked = !isAdding; diff --git a/templates/profile/partner/exhibition.html b/templates/profile/partner/exhibition.html index c16d24e..bd3a507 100644 --- a/templates/profile/partner/exhibition.html +++ b/templates/profile/partner/exhibition.html @@ -7,7 +7,7 @@ {% import "components/label.html" as label %} {% import "/components/contacts.html" as contacts %} -{% block title %}Отобранные картины | Выставка {{exhibition.title}}{% endblock %} +{% block title %}Отправить приглашения | Выставка {{exhibition.title}}{% endblock %} {% block content %} From f776d6ce253e74aa00f48f9c04f24600d3c84da134c056ad666cef7b21d40ff7 Mon Sep 17 00:00:00 2001 From: vladislav Date: Mon, 14 Sep 2026 06:59:06 -0400 Subject: [PATCH 03/16] fix --- main.ipynb | 4 ++-- main.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/main.ipynb b/main.ipynb index 5a85ded..978aa16 100644 --- a/main.ipynb +++ b/main.ipynb @@ -4998,8 +4998,8 @@ " \n", " data = [doc async for doc in get_delivery_aggregate(_match)]\n", "\n", - " exhibitions = await exhibition_collection.find({'_id': {'$in': [ObjectId(i.get('exhibition_id')) for i in data if i.get('exhibition_id')]}}).to_list()\n", - " competitions = await competitions_col.find({'_id': {'$in': [i.get('competition_id') for i in data if i.get('competition_id')]}}).to_list()\n", + " exhibitions = await exhibition_collection.find({'_id': {'$in': [ObjectId(i.get('exhibition_id')) for i in data if i.get('exhibition_id')]}}).sort(\"_id\", -1).to_list()\n", + " competitions = await competitions_col.find({'_id': {'$in': [i.get('competition_id') for i in data if i.get('competition_id')]}}).sort(\"_id\", -1).to_list()\n", " return templates.TemplateResponse(\n", " request=request,\n", " name=\"profile/delivery.html\",\n", diff --git a/main.py b/main.py index fd3ab3c..9756cf0 100644 --- a/main.py +++ b/main.py @@ -3818,8 +3818,8 @@ async def profile_delivery( data = [doc async for doc in get_delivery_aggregate(_match)] - exhibitions = await exhibition_collection.find({'_id': {'$in': [ObjectId(i.get('exhibition_id')) for i in data if i.get('exhibition_id')]}}).to_list() - competitions = await competitions_col.find({'_id': {'$in': [i.get('competition_id') for i in data if i.get('competition_id')]}}).to_list() + exhibitions = await exhibition_collection.find({'_id': {'$in': [ObjectId(i.get('exhibition_id')) for i in data if i.get('exhibition_id')]}}).sort("_id", -1).to_list() + competitions = await competitions_col.find({'_id': {'$in': [i.get('competition_id') for i in data if i.get('competition_id')]}}).sort("_id", -1).to_list() return templates.TemplateResponse( request=request, name="profile/delivery.html", From b06a678fb25161201a179a047e827fcefc6dfa7ce47182776050ddb1afe0e5f3 Mon Sep 17 00:00:00 2001 From: vladislav Date: Mon, 14 Sep 2026 07:20:54 -0400 Subject: [PATCH 04/16] hore --- build.ipynb | 153 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 135 insertions(+), 18 deletions(-) diff --git a/build.ipynb b/build.ipynb index 9405dda..bb3488d 100644 --- a/build.ipynb +++ b/build.ipynb @@ -817,6 +817,35 @@ ], "display_name": ".venv" }, + { + "cell_type": "code", + "execution_count": null, + "id": "434ecf4b", + "metadata": { + "vscode": { + "languageId": "shellscript" + } + }, + "outputs": [], + "source": [ + "%%bash\n", + "ssh kultmost 'tee /app/production/start-uvicorn-back.sh >/dev/null' <<'EOF'\n", + "#!/usr/bin/bash\n", + "set -euo pipefail\n", + "\n", + "workers=\"$(nproc)\"\n", + "\n", + "exec /app/production/.venv/bin/uvicorn main:app \\\n", + " --host 127.0.0.1 \\\n", + " --port 30080 \\\n", + " --workers \"$workers\"\n", + "EOF\n", + "\n", + "ssh kultmost 'chmod +x /app/production/start-uvicorn-back.sh'\n", + "ssh kultmost 'ls -ln /app/production/start-uvicorn-back.sh'" + ], + "display_name": ".venv" + }, { "cell_type": "code", "execution_count": null, @@ -828,23 +857,24 @@ }, "outputs": [], "source": [ - "# %%bash\n", - "# ssh kultmost 'tee /etc/systemd/system/fastapi.service >/dev/null' <<'EOF'\n", - "# [Unit]\n", - "# Description=FastAPI prod service\n", - "# After=network.target\n", + "%%bash\n", + "ssh kultmost 'tee /etc/systemd/system/fastapi.service >/dev/null' <<'EOF'\n", + "[Unit]\n", + "Description=FastAPI prod service\n", + "After=network.target\n", "\n", - "# [Service]\n", - "# WorkingDirectory=/app/production\n", - "# ExecStart=/app/production/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 30080 --workers 8\n", - "# Restart=always\n", - "# EnvironmentFile=/app/production/env\n", + "[Service]\n", + "WorkingDirectory=/app/production\n", + "ExecStart=/app/production/start-uvicorn-back.sh\n", + "Restart=always\n", + "EnvironmentFile=/app/production/env\n", + "CPUWeight=60\n", "\n", - "# [Install]\n", - "# WantedBy=default.target\n", - "# EOF\n", + "[Install]\n", + "WantedBy=multi-user.target\n", + "EOF\n", "\n", - "# ssh kultmost 'systemctl daemon-reload && systemctl enable --now fastapi.service && systemctl status --no-pager -l fastapi.service'\n" + "ssh kultmost 'systemctl daemon-reload && systemctl enable --now fastapi.service && systemctl status --no-pager -l fastapi.service'\n" ], "display_name": ".venv" }, @@ -960,9 +990,75 @@ "id": "0474516d", "metadata": {}, "source": [ - "### restart fastapi" + "### restart production " ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "5d35489f", + "metadata": { + "vscode": { + "languageId": "shellscript" + } + }, + "outputs": [], + "source": [ + "!ssh kultmost 'systemctl start fastapi.service'" + ], + "display_name": ".venv" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c477dcdd", + "metadata": { + "vscode": { + "languageId": "shellscript" + } + }, + "outputs": [], + "source": [ + "!ssh kultmost 'systemctl status fastapi.service'" + ], + "display_name": ".venv" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1eccaed4", + "metadata": { + "vscode": { + "languageId": "shellscript" + } + }, + "outputs": [], + "source": [ + "%%bash\n", + "ssh kultmost 'tee /etc/nginx/sites-enabled/avtopost_back >/dev/null' <<'EOF'\n", + "upstream backend_avtopost_servers {\n", + " server 127.0.0.1:30080;\n", + "}\n", + "EOF\n", + "ssh kultmost 'nginx -t'\n" + ], + "display_name": ".venv" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11a407c2", + "metadata": { + "vscode": { + "languageId": "shellscript" + } + }, + "outputs": [], + "source": [ + "!ssh kultmost 'nginx -s reload'" + ], + "display_name": ".venv" + }, { "cell_type": "code", "execution_count": null, @@ -996,7 +1092,7 @@ { "cell_type": "code", "execution_count": null, - "id": "6fdc2d37", + "id": "1cef4109", "metadata": { "vscode": { "languageId": "shellscript" @@ -1004,7 +1100,28 @@ }, "outputs": [], "source": [ - "# !ssh kultmost 'systemctl restart fastapi.service'" + "%%bash\n", + "ssh kultmost 'tee /etc/nginx/sites-enabled/avtopost_back >/dev/null' <<'EOF'\n", + "upstream backend_avtopost_servers {\n", + " server 127.0.0.1:30081;\n", + "}\n", + "EOF\n", + "ssh kultmost 'nginx -t'" + ], + "display_name": ".venv" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42b4958a", + "metadata": { + "vscode": { + "languageId": "shellscript" + } + }, + "outputs": [], + "source": [ + "!ssh kultmost 'nginx -s reload'" ], "display_name": ".venv" }, @@ -1019,7 +1136,7 @@ }, "outputs": [], "source": [ - "# !ssh kultmost 'systemctl status fastapi.service'" + "!ssh kultmost 'systemctl stop fastapi.service'" ], "display_name": ".venv" }, From 5f8a1bebf7946daa6df418996f00352052b9cc5064bb87cb8e633c7eeb809910 Mon Sep 17 00:00:00 2001 From: vladislav Date: Mon, 14 Sep 2026 08:12:52 -0400 Subject: [PATCH 05/16] feat --- delivery_router.py | 2 +- main.ipynb | 45 ++++++++++++++++++------- main.py | 43 ++++++++++++++++------- templates/profile/delivery.html | 2 +- templates/profile/delivery_history.html | 2 +- templates/profile/delivery_old.html | 2 +- templates/profile/invitation.html | 14 ++++---- 7 files changed, 74 insertions(+), 36 deletions(-) diff --git a/delivery_router.py b/delivery_router.py index 8b7b2be..df3f449 100644 --- a/delivery_router.py +++ b/delivery_router.py @@ -125,7 +125,7 @@ async def accept_delivery( new_doc = { **existing, - "status": "Получено", + "status": "Получено галерей" if 'partner' in current_user.get('roles', []) else "Получено художником", "notify": notify.upserted_id, "received_at": datetime.utcnow(), "updated_at": datetime.utcnow(), diff --git a/main.ipynb b/main.ipynb index 978aa16..3a26c15 100644 --- a/main.ipynb +++ b/main.ipynb @@ -180,23 +180,42 @@ "metadata": {}, "outputs": [], "source": [ - "def get_delivery_aggregate(_filter: dict = {}, _status: str = None):\n", + "from typing import Any, AsyncIterator\n", + "\n", + "\n", + "def get_delivery_aggregate(\n", + " _filter: dict[str, Any] | None = None,\n", + " _status: str | list[str] | None = None,\n", + "):\n", " cursor = delivery_collection.aggregate([\n", - " {\"$match\": {\n", - " \"prod\": PROD, **_filter\n", - " }},\n", + " {\n", + " \"$match\": {\n", + " \"prod\": PROD,\n", + " **(_filter or {}),\n", + " }\n", + " },\n", " {\"$sort\": {\"_id\": -1}},\n", - " {\"$group\": {\n", - " \"_id\": \"$post_id\",\n", - " \"doc\": {\"$first\": \"$$ROOT\"}\n", - " }},\n", + " {\n", + " \"$group\": {\n", + " \"_id\": \"$post_id\",\n", + " \"doc\": {\"$first\": \"$$ROOT\"},\n", + " }\n", + " },\n", " {\"$replaceRoot\": {\"newRoot\": \"$doc\"}},\n", " {\"$sort\": {\"_id\": -1}},\n", " ])\n", - " if _status:\n", - " return (doc async for doc in cursor if doc.get('status') == _status)\n", - " else:\n", - " return cursor\n" + "\n", + " if _status is None:\n", + " return cursor\n", + "\n", + " statuses = {_status} if isinstance(_status, str) else set(_status)\n", + "\n", + " async def filtered_cursor() -> AsyncIterator[dict[str, Any]]:\n", + " async for doc in cursor:\n", + " if doc.get(\"status\") in statuses:\n", + " yield doc\n", + "\n", + " return filtered_cursor()\n" ], "display_name": ".venv" }, @@ -1689,7 +1708,7 @@ "):\n", " exhibition = await exhibition_collection.find_one({'_id': ObjectId(exhibition_id)})\n", " if exhibition:\n", - " posts = [doc async for doc in get_delivery_aggregate({\"exhibition_id\": exhibition_id}, 'На выставке')]\n", + " posts = [doc async for doc in get_delivery_aggregate({\"exhibition_id\": exhibition_id}, ['Получено', 'Получено галерей', 'Продано', 'На выставке'])]\n", " \n", " return templates.TemplateResponse(\n", " request=request, name=\"exhibition/index.html\", context={\"id\": 1, \"user\": user, \"exhibition\": exhibition, \"posts\": posts, 'now': datetime.now()}\n", diff --git a/main.py b/main.py index 9756cf0..d4a453e 100644 --- a/main.py +++ b/main.py @@ -81,24 +81,43 @@ def transliterate(text: str) -> str: return ''.join(RUS_TO_LAT.get(ch, '' if ch not in string.ascii_letters else ch) for ch in text) -def get_delivery_aggregate(_filter: dict = {}, _status: str = None): +from typing import Any, AsyncIterator + + +def get_delivery_aggregate( + _filter: dict[str, Any] | None = None, + _status: str | list[str] | None = None, +): cursor = delivery_collection.aggregate([ - {"$match": { - "prod": PROD, **_filter - }}, + { + "$match": { + "prod": PROD, + **(_filter or {}), + } + }, {"$sort": {"_id": -1}}, - {"$group": { - "_id": "$post_id", - "doc": {"$first": "$$ROOT"} - }}, + { + "$group": { + "_id": "$post_id", + "doc": {"$first": "$$ROOT"}, + } + }, {"$replaceRoot": {"newRoot": "$doc"}}, {"$sort": {"_id": -1}}, ]) - if _status: - return (doc async for doc in cursor if doc.get('status') == _status) - else: + + if _status is None: return cursor + statuses = {_status} if isinstance(_status, str) else set(_status) + + async def filtered_cursor() -> AsyncIterator[dict[str, Any]]: + async for doc in cursor: + if doc.get("status") in statuses: + yield doc + + return filtered_cursor() + async def get_latest_news() -> List[dict]: cursor = news_collection.find({'publish_on_platform': True}).sort("_id", -1).limit(30) @@ -1260,7 +1279,7 @@ async def exhibition_members( ): exhibition = await exhibition_collection.find_one({'_id': ObjectId(exhibition_id)}) if exhibition: - posts = [doc async for doc in get_delivery_aggregate({"exhibition_id": exhibition_id}, 'На выставке')] + posts = [doc async for doc in get_delivery_aggregate({"exhibition_id": exhibition_id}, ['Получено', 'Получено галерей', 'Продано', 'На выставке'])] return templates.TemplateResponse( request=request, name="exhibition/index.html", context={"id": 1, "user": user, "exhibition": exhibition, "posts": posts, 'now': datetime.now()} diff --git a/templates/profile/delivery.html b/templates/profile/delivery.html index 09750ba..90b8e83 100644 --- a/templates/profile/delivery.html +++ b/templates/profile/delivery.html @@ -17,7 +17,7 @@
{{doc.status}}
- {% elif doc.status == 'Получено' %} + {% elif Получено' in (doc.status or '') %}
{{doc.status}}
diff --git a/templates/profile/delivery_history.html b/templates/profile/delivery_history.html index 19810b9..c314832 100644 --- a/templates/profile/delivery_history.html +++ b/templates/profile/delivery_history.html @@ -33,7 +33,7 @@
Запрос на отправку
- {% elif doc.status == 'Получено' %} + {% elif Получено' in (doc.status or '') %}
{{doc.status}}
diff --git a/templates/profile/delivery_old.html b/templates/profile/delivery_old.html index 07ad6d8..79aa7e2 100644 --- a/templates/profile/delivery_old.html +++ b/templates/profile/delivery_old.html @@ -17,7 +17,7 @@
{{doc.status}}
- {% elif doc.status == 'Получено' %} + {% elif Получено' in (doc.status or '') %}
{{doc.status}}
diff --git a/templates/profile/invitation.html b/templates/profile/invitation.html index e87262f..5b712ed 100644 --- a/templates/profile/invitation.html +++ b/templates/profile/invitation.html @@ -12,21 +12,21 @@
- {% if doc.status == 'Ожидает отправки' %} + {% if (doc.status or '') == 'Ожидает отправки' %}
- {{doc.status}} + {{(doc.status or '')}}
- {% elif doc.status == 'Получено' %} + {% elif 'Получено' in (doc.status or '') %}
- {{doc.status}} + {{(doc.status or '')}}
- {% elif doc.status == 'Ожидает получения' %} + {% elif (doc.status or '') == 'Ожидает получения' %}
- {{doc.status}} + {{(doc.status or '')}}
{% else %}
- {{doc.status}} + {{(doc.status or '')}}
{% endif %} Посмотреть историю From 1509a935438198ebac3fb63866b09607bc493219862d4c27e6e447364e21ac32 Mon Sep 17 00:00:00 2001 From: vladislav Date: Mon, 14 Sep 2026 08:19:49 -0400 Subject: [PATCH 06/16] fix --- templates/exhibition/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/exhibition/index.html b/templates/exhibition/index.html index 1c7f930..4c9f98b 100644 --- a/templates/exhibition/index.html +++ b/templates/exhibition/index.html @@ -94,7 +94,7 @@ {% set post=doc.get('post')%}
- + {{ post.title }} From e971d2d04668d22cdf76881ba72ffc17e2c78b94fc4382efc21fd8f58367d508 Mon Sep 17 00:00:00 2001 From: vladislav Date: Mon, 14 Sep 2026 08:20:51 -0400 Subject: [PATCH 07/16] fix --- templates/exhibition/index.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/templates/exhibition/index.html b/templates/exhibition/index.html index 4c9f98b..b7e9ab4 100644 --- a/templates/exhibition/index.html +++ b/templates/exhibition/index.html @@ -92,6 +92,7 @@
{% for doc in posts %} {% set post=doc.get('post')%} + {% if post %}
@@ -123,6 +124,7 @@

{{ (post._id.generation_time.strftime('%d.%m.%Y')) if post._id else ''}}

+ {% endif %} {% endfor %}
From 209eee9f5c8c2a3d379c795e9130508a3332ee9c19cb4b846a68766ef93eec8e Mon Sep 17 00:00:00 2001 From: vladislav Date: Mon, 14 Sep 2026 08:28:14 -0400 Subject: [PATCH 08/16] fix --- main.ipynb | 4 +++- main.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/main.ipynb b/main.ipynb index 3a26c15..046a0b3 100644 --- a/main.ipynb +++ b/main.ipynb @@ -1709,7 +1709,9 @@ " exhibition = await exhibition_collection.find_one({'_id': ObjectId(exhibition_id)})\n", " if exhibition:\n", " posts = [doc async for doc in get_delivery_aggregate({\"exhibition_id\": exhibition_id}, ['Получено', 'Получено галерей', 'Продано', 'На выставке'])]\n", - " \n", + " for i in posts:\n", + " if (post_id:=i.get('post_id')) and 'post' not in i:\n", + " i['post'] = posts_collection.find_one({'_id': ObjectId(post_id)})\n", " return templates.TemplateResponse(\n", " request=request, name=\"exhibition/index.html\", context={\"id\": 1, \"user\": user, \"exhibition\": exhibition, \"posts\": posts, 'now': datetime.now()}\n", " )\n", diff --git a/main.py b/main.py index d4a453e..65e2d00 100644 --- a/main.py +++ b/main.py @@ -1280,7 +1280,9 @@ async def exhibition_members( exhibition = await exhibition_collection.find_one({'_id': ObjectId(exhibition_id)}) if exhibition: posts = [doc async for doc in get_delivery_aggregate({"exhibition_id": exhibition_id}, ['Получено', 'Получено галерей', 'Продано', 'На выставке'])] - + for i in posts: + if (post_id:=i.get('post_id')) and 'post' not in i: + i['post'] = posts_collection.find_one({'_id': ObjectId(post_id)}) return templates.TemplateResponse( request=request, name="exhibition/index.html", context={"id": 1, "user": user, "exhibition": exhibition, "posts": posts, 'now': datetime.now()} ) From c9762fd9599c091a447ff74f107b44bff0fbe7a3ae20a08f8aec92304993c74e Mon Sep 17 00:00:00 2001 From: vladislav Date: Mon, 14 Sep 2026 08:29:48 -0400 Subject: [PATCH 09/16] fix --- templates/competition/index.html | 2 +- templates/competition/member/index.html | 2 +- templates/exhibition/index.html | 2 +- templates/profile/competition/select.html | 2 +- templates/profile/partner/competition.html | 2 +- templates/profile/partner/competition_member.html | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/templates/competition/index.html b/templates/competition/index.html index 6ed25ef..6f92076 100644 --- a/templates/competition/index.html +++ b/templates/competition/index.html @@ -97,7 +97,7 @@
{{ post.title }} + src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
diff --git a/templates/competition/member/index.html b/templates/competition/member/index.html index 8eac798..ceb063a 100644 --- a/templates/competition/member/index.html +++ b/templates/competition/member/index.html @@ -73,7 +73,7 @@
{{ post.title }} + src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
diff --git a/templates/exhibition/index.html b/templates/exhibition/index.html index b7e9ab4..cc75938 100644 --- a/templates/exhibition/index.html +++ b/templates/exhibition/index.html @@ -97,7 +97,7 @@
{{ post.title }} + src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
diff --git a/templates/profile/competition/select.html b/templates/profile/competition/select.html index f5f4698..97c8da7 100644 --- a/templates/profile/competition/select.html +++ b/templates/profile/competition/select.html @@ -4,7 +4,7 @@
{{ post.title }} + src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
diff --git a/templates/profile/partner/competition.html b/templates/profile/partner/competition.html index c682a22..f830be5 100644 --- a/templates/profile/partner/competition.html +++ b/templates/profile/partner/competition.html @@ -148,7 +148,7 @@
{{ post.title }}
diff --git a/templates/profile/partner/competition_member.html b/templates/profile/partner/competition_member.html index 93c5cd6..20b5cf4 100644 --- a/templates/profile/partner/competition_member.html +++ b/templates/profile/partner/competition_member.html @@ -176,7 +176,7 @@ failBtn.addEventListener('click', () => selectMember('fail'));
{{ post.title }} + src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
From d140c120ca835bf7d76b4b50abc679d5831f161d2d9de839061310c03fd44673 Mon Sep 17 00:00:00 2001 From: vladislav Date: Mon, 14 Sep 2026 08:35:13 -0400 Subject: [PATCH 10/16] fix --- main.ipynb | 2 +- main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/main.ipynb b/main.ipynb index 046a0b3..9b43bd2 100644 --- a/main.ipynb +++ b/main.ipynb @@ -1711,7 +1711,7 @@ " posts = [doc async for doc in get_delivery_aggregate({\"exhibition_id\": exhibition_id}, ['Получено', 'Получено галерей', 'Продано', 'На выставке'])]\n", " for i in posts:\n", " if (post_id:=i.get('post_id')) and 'post' not in i:\n", - " i['post'] = posts_collection.find_one({'_id': ObjectId(post_id)})\n", + " i['post'] = await posts_collection.find_one({'_id': ObjectId(post_id)})\n", " return templates.TemplateResponse(\n", " request=request, name=\"exhibition/index.html\", context={\"id\": 1, \"user\": user, \"exhibition\": exhibition, \"posts\": posts, 'now': datetime.now()}\n", " )\n", diff --git a/main.py b/main.py index 65e2d00..f8cbf48 100644 --- a/main.py +++ b/main.py @@ -1282,7 +1282,7 @@ async def exhibition_members( posts = [doc async for doc in get_delivery_aggregate({"exhibition_id": exhibition_id}, ['Получено', 'Получено галерей', 'Продано', 'На выставке'])] for i in posts: if (post_id:=i.get('post_id')) and 'post' not in i: - i['post'] = posts_collection.find_one({'_id': ObjectId(post_id)}) + i['post'] = await posts_collection.find_one({'_id': ObjectId(post_id)}) return templates.TemplateResponse( request=request, name="exhibition/index.html", context={"id": 1, "user": user, "exhibition": exhibition, "posts": posts, 'now': datetime.now()} ) From 77adc87b3c0b48ff6a081574690a10ec6baabf236513980e894a89640d2a2645 Mon Sep 17 00:00:00 2001 From: vladislav Date: Mon, 14 Sep 2026 08:45:00 -0400 Subject: [PATCH 11/16] hotfix --- templates/profile/delivery.html | 2 +- templates/profile/delivery_history.html | 2 +- templates/profile/delivery_old.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/profile/delivery.html b/templates/profile/delivery.html index 90b8e83..e22747e 100644 --- a/templates/profile/delivery.html +++ b/templates/profile/delivery.html @@ -17,7 +17,7 @@
{{doc.status}}
- {% elif Получено' in (doc.status or '') %} + {% elif 'Получено' in (doc.status or '') %}
{{doc.status}}
diff --git a/templates/profile/delivery_history.html b/templates/profile/delivery_history.html index c314832..cbc156d 100644 --- a/templates/profile/delivery_history.html +++ b/templates/profile/delivery_history.html @@ -33,7 +33,7 @@
Запрос на отправку
- {% elif Получено' in (doc.status or '') %} + {% elif 'Получено' in (doc.status or '') %}
{{doc.status}}
diff --git a/templates/profile/delivery_old.html b/templates/profile/delivery_old.html index 79aa7e2..ab89691 100644 --- a/templates/profile/delivery_old.html +++ b/templates/profile/delivery_old.html @@ -17,7 +17,7 @@
{{doc.status}}
- {% elif Получено' in (doc.status or '') %} + {% elif 'Получено' in (doc.status or '') %}
{{doc.status}}
From 1a39c29f683b1c77f0c1e76f0bc947ac7a2b58c54bcfdfbd249043f659689f0c Mon Sep 17 00:00:00 2001 From: vladislav Date: Mon, 14 Sep 2026 11:16:50 -0400 Subject: [PATCH 12/16] feat --- templates/profile/delivery.html | 146 +++++++++++++++++--------------- 1 file changed, 79 insertions(+), 67 deletions(-) diff --git a/templates/profile/delivery.html b/templates/profile/delivery.html index e22747e..8a19301 100644 --- a/templates/profile/delivery.html +++ b/templates/profile/delivery.html @@ -101,99 +101,111 @@
{{ profile_nav.profile_nav(user, 'delivery') }}
-{% for competition in competitions %} -
- -
- - {{ competition.title }} - -
-
+
+ Конкурсы + {% for competition in competitions %} +
+ +
+ + {{ competition.title }} + +
+
-
- {% endfor %} - {% for i in exhibitions %} - - {% endfor %} +
+ {% endfor %} + +
+ Выставки + {% for i in exhibitions %} + + {% endfor %} +
From 66c604cbea9bad486d61768342094c797810f0c2ad930c41aab99caa2e45cc4c Mon Sep 17 00:00:00 2001 From: vladislav Date: Mon, 14 Sep 2026 11:35:25 -0400 Subject: [PATCH 13/16] style --- templates/profile/delivery.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/templates/profile/delivery.html b/templates/profile/delivery.html index 8a19301..fe467ec 100644 --- a/templates/profile/delivery.html +++ b/templates/profile/delivery.html @@ -103,6 +103,7 @@
Конкурсы +
{% for competition in competitions %}
{% endfor %} +
-
+
Выставки + {% endfor %} +
@@ -220,6 +224,7 @@ {{ profile_nav.style() }} {{card.style()}}