diff --git a/build.ipynb b/build.ipynb
index 9405dda..2cdf43c 100644
--- a/build.ipynb
+++ b/build.ipynb
@@ -695,6 +695,23 @@
"## RSYNC"
]
},
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "ead8aa36",
+ "metadata": {
+ "vscode": {
+ "languageId": "shellscript"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "%%bash\n",
+ "ssh kultmost 'mkdir -p /var/lib/app/.gnupg'\n",
+ "ssh kultmost 'chmod 700 /var/lib/app/.gnupg'"
+ ],
+ "display_name": ".venv"
+ },
{
"cell_type": "markdown",
"id": "0a429fca",
@@ -817,6 +834,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 +874,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 +1007,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 +1109,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "6fdc2d37",
+ "id": "1cef4109",
"metadata": {
"vscode": {
"languageId": "shellscript"
@@ -1004,7 +1117,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 +1153,7 @@
},
"outputs": [],
"source": [
- "# !ssh kultmost 'systemctl status fastapi.service'"
+ "!ssh kultmost 'systemctl stop fastapi.service'"
],
"display_name": ".venv"
},
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/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/main.ipynb b/main.ipynb
index 5a85ded..9b43bd2 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,8 +1708,10 @@
"):\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",
- " \n",
+ " 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'] = 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",
@@ -4998,8 +5019,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..f8cbf48 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,8 +1279,10 @@ 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}, ['Получено', 'Получено галерей', 'Продано', 'На выставке'])]
+ for i in posts:
+ if (post_id:=i.get('post_id')) and 'post' not in i:
+ 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()}
)
@@ -3818,8 +3839,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",
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/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 @@
+ 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 @@
+ 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 1c7f930..cc75938 100644
--- a/templates/exhibition/index.html
+++ b/templates/exhibition/index.html
@@ -92,11 +92,12 @@
{% for doc in posts %}
{% set post=doc.get('post')%}
+ {% if post %}
-
+
+ src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
@@ -123,6 +124,7 @@
{{ (post._id.generation_time.strftime('%d.%m.%Y')) if post._id else ''}}
+ {% endif %}
{% endfor %}
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 @@
+ src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
diff --git a/templates/profile/delivery.html b/templates/profile/delivery.html
index 09750ba..f49a4aa 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}}
@@ -101,99 +101,115 @@
{{ profile_nav.profile_nav(user, 'delivery') }}
-{% for competition in competitions %}
-
-
-
-
-
-
-
-
+
+ {% for competition in competitions %}
+
+
+
+
+
+
+
+
-
+
- Конкурс
+ Конкурс
{% if competition.get('status',-1) == 2 %}
- Закончился
+ Закончился
{% elif competition.get('status',-1) == 1 %}
- Идет отбор
+ Идет отбор
{% else %}
- {{ {-1:'Формируется',0:'Прием заявок',1:'Идет отбор',2:'Закончился'}[competition.get('status',-1)] }}
+ {{ {-1:'Формируется',0:'Прием заявок',1:'Идет
+ отбор',2:'Закончился'}[competition.get('status',-1)] }}
{% endif %}
- {{ competition.title }}
+ {{ competition.title }}
{{ competition.topic }}
{% if profile %}
{% else %}
{% set user = competition.get('user') %}
- {% if user %}
-

-
-
{{ user.username | e }}
-
- {% endif %}
+ {% if user %}
+

+
+
{{ user.username | e }}
+
+ {% endif %}
Подробнее
{% endif %}
-
-
{{ (competition.datetime_start.strftime('%d.%m.%Y')) if competition.datetime_start else ''}}
+
+
{{ (competition.datetime_start.strftime('%d.%m.%Y')) if competition.datetime_start else
+ ''}}
-
- {% endfor %}
- {% for i in exhibitions %}
-
-
-
-
-
-
-
-
-
-
- {% endfor %}
+
+ {% endfor %}
+
+ {% for i in exhibitions %}
+
+
+
+
+
+
+
+
+
+
+ {% endfor %}
+
@@ -208,6 +224,7 @@
{{ profile_nav.style() }}
{{card.style()}}