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

This commit is contained in:
vladislav 2026-09-14 23:12:39 +00:00
commit c8ee828804
21 changed files with 817 additions and 224 deletions

View File

@ -695,6 +695,23 @@
"## RSYNC" "## 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", "cell_type": "markdown",
"id": "0a429fca", "id": "0a429fca",
@ -817,6 +834,35 @@
], ],
"display_name": ".venv" "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", "cell_type": "code",
"execution_count": null, "execution_count": null,
@ -828,23 +874,24 @@
}, },
"outputs": [], "outputs": [],
"source": [ "source": [
"# %%bash\n", "%%bash\n",
"# ssh kultmost 'tee /etc/systemd/system/fastapi.service >/dev/null' <<'EOF'\n", "ssh kultmost 'tee /etc/systemd/system/fastapi.service >/dev/null' <<'EOF'\n",
"# [Unit]\n", "[Unit]\n",
"# Description=FastAPI prod service\n", "Description=FastAPI prod service\n",
"# After=network.target\n", "After=network.target\n",
"\n", "\n",
"# [Service]\n", "[Service]\n",
"# WorkingDirectory=/app/production\n", "WorkingDirectory=/app/production\n",
"# ExecStart=/app/production/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 30080 --workers 8\n", "ExecStart=/app/production/start-uvicorn-back.sh\n",
"# Restart=always\n", "Restart=always\n",
"# EnvironmentFile=/app/production/env\n", "EnvironmentFile=/app/production/env\n",
"CPUWeight=60\n",
"\n", "\n",
"# [Install]\n", "[Install]\n",
"# WantedBy=default.target\n", "WantedBy=multi-user.target\n",
"# EOF\n", "EOF\n",
"\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" "display_name": ".venv"
}, },
@ -960,9 +1007,75 @@
"id": "0474516d", "id": "0474516d",
"metadata": {}, "metadata": {},
"source": [ "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", "cell_type": "code",
"execution_count": null, "execution_count": null,
@ -996,7 +1109,7 @@
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": null,
"id": "6fdc2d37", "id": "1cef4109",
"metadata": { "metadata": {
"vscode": { "vscode": {
"languageId": "shellscript" "languageId": "shellscript"
@ -1004,7 +1117,28 @@
}, },
"outputs": [], "outputs": [],
"source": [ "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" "display_name": ".venv"
}, },
@ -1019,7 +1153,7 @@
}, },
"outputs": [], "outputs": [],
"source": [ "source": [
"# !ssh kultmost 'systemctl status fastapi.service'" "!ssh kultmost 'systemctl stop fastapi.service'"
], ],
"display_name": ".venv" "display_name": ".venv"
}, },

View File

@ -125,7 +125,7 @@ async def accept_delivery(
new_doc = { new_doc = {
**existing, **existing,
"status": "Получено", "status": "Получено галерей" if 'partner' in current_user.get('roles', []) else "Получено художником",
"notify": notify.upserted_id, "notify": notify.upserted_id,
"received_at": datetime.utcnow(), "received_at": datetime.utcnow(),
"updated_at": datetime.utcnow(), "updated_at": datetime.utcnow(),

View File

@ -38,7 +38,8 @@
" load_env('env')\n", " load_env('env')\n",
"except:\n", "except:\n",
" load_env('env_example')" " load_env('env_example')"
] ],
"display_name": ".venv"
}, },
{ {
"cell_type": "code", "cell_type": "code",
@ -48,17 +49,33 @@
"outputs": [], "outputs": [],
"source": [ "source": [
"import smtplib\n", "import smtplib\n",
"import os\n",
"from email.mime.application import MIMEApplication\n",
"from email.mime.multipart import MIMEMultipart\n", "from email.mime.multipart import MIMEMultipart\n",
"from email.mime.text import MIMEText\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 celery import Celery\n",
"from config import *\n", "from config import *\n",
"from db import *\n", "from db import *\n",
"from templates import templates\n", "from templates import templates\n",
"import logging\n", "import logging\n",
"from typing import Optional\n", "import asyncio \n"
"import asyncio \n", ],
"from email.utils import formatdate, make_msgid\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", "cell_type": "code",
@ -67,37 +84,211 @@
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": [
"def build_unsubscribe_url(email: str, token: str, base_url: str) -> str:\n", "GPG_HOME = os.getenv(\"GPG_HOME\", \"/var/lib/app/.gnupg\")\n",
" return f\"{base_url}/api/v1/email/unsubscribe?token={token}&email={email}\"\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",
"\n", "\n",
"async def create_email_message(\n", "async def create_email_message(\n",
" to_email: str,\n", " to_email: str,\n",
" subject: str,\n", " subject: str,\n",
" html_content: str,\n", " html_content: str,\n",
" user_id: int = None,\n", " user_id: Optional[int] = None,\n",
"):\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", " msg = MIMEMultipart(\"alternative\")\n",
" \n", "\n",
" msg['From'] = f\"{FROM_NAME} <{FROM_EMAIL}>\"\n", " msg[\"From\"] = f\"{FROM_NAME} <{FROM_EMAIL}>\"\n",
" msg['To'] = to_email\n", " msg[\"To\"] = to_email\n",
" msg['Subject'] = subject\n", " msg[\"Subject\"] = subject\n",
" msg['Message-ID'] = make_msgid(domain=FROM_EMAIL.split('@')[1])\n", " msg[\"Message-ID\"] = make_msgid(\n",
" msg['Date'] = formatdate(localtime=True)\n", " domain=FROM_EMAIL.rsplit(\"@\", 1)[1],\n",
" msg['X-Priority'] = \"3\"\n", " )\n",
" msg['Importance'] = \"normal\"\n", " msg[\"Date\"] = formatdate(localtime=True)\n",
" msg['X-Auto-Response-Suppress'] = \"All\"\n", " msg[\"X-Priority\"] = \"3\"\n",
" \n", " msg[\"Importance\"] = \"normal\"\n",
" # token = await create_unsubscribe_token(to_email, user_id)\n", " msg[\"X-Auto-Response-Suppress\"] = \"All\"\n",
" unsubscribe_url = build_unsubscribe_url(to_email, None, FULL_DOMAIN)\n", "\n",
" if to_email != FROM_EMAIL:\n", " # Do not send unsubscribe headers to yourself.\n",
" msg['List-Unsubscribe'] = f\"<{unsubscribe_url}>\"\n", " if to_email.lower() != FROM_EMAIL.lower():\n",
" msg['List-Unsubscribe-Post'] = \"List-Unsubscribe=One-Click\"\n", " unsubscribe_url = build_unsubscribe_url(\n",
" \n", " to_email=to_email,\n",
" msg.attach(MIMEText(html_content, 'html'))\n", " user_id=user_id,\n",
" \n", " domain=FULL_DOMAIN,\n",
" return msg\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", "cell_type": "code",
@ -140,7 +331,8 @@
" f\"Subject: {subject}, Error: {str(e)}\"\n", " f\"Subject: {subject}, Error: {str(e)}\"\n",
" )\n", " )\n",
" return False\n" " return False\n"
] ],
"display_name": ".venv"
}, },
{ {
"cell_type": "code", "cell_type": "code",
@ -150,7 +342,8 @@
"outputs": [], "outputs": [],
"source": [ "source": [
"celery_app_email = Celery('emails', broker=f'{redis_uri}2', backend=f'{redis_uri}2', task_track_started=True)\n" "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", "cell_type": "code",
@ -189,7 +382,8 @@
" f\"Ошибка при отправке email на {to_email} (попытка {self.request.retries + 1}): {exc}\"\n", " f\"Ошибка при отправке email на {to_email} (попытка {self.request.retries + 1}): {exc}\"\n",
" )\n", " )\n",
" raise self.retry(exc=exc)\n" " raise self.retry(exc=exc)\n"
] ],
"display_name": ".venv"
}, },
{ {
"cell_type": "markdown", "cell_type": "markdown",
@ -199,6 +393,18 @@
"## Run" "## 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", "cell_type": "code",
"execution_count": null, "execution_count": null,
@ -228,7 +434,8 @@
"\n", "\n",
" with open(f'{filename_without_extension}.py', 'w') as f:\n", " with open(f'{filename_without_extension}.py', 'w') as f:\n",
" f.write('\\n\\n'.join(app))\n" " f.write('\\n\\n'.join(app))\n"
] ],
"display_name": ".venv"
}, },
{ {
"cell_type": "code", "cell_type": "code",
@ -238,7 +445,8 @@
"outputs": [], "outputs": [],
"source": [ "source": [
"!celery -A email_task.celery_app_email worker --loglevel=info" "!celery -A email_task.celery_app_email worker --loglevel=info"
] ],
"display_name": ".venv"
} }
], ],
"metadata": { "metadata": {

View File

@ -32,48 +32,228 @@ except:
load_env('env_example') load_env('env_example')
import smtplib import smtplib
import os
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.utils import formatdate, make_msgid
from typing import Optional
import gnupg
from celery import Celery from celery import Celery
from config import * from config import *
from db import * from db import *
from templates import templates from templates import templates
import logging import logging
from typing import Optional
import asyncio import asyncio
from email.utils import formatdate, make_msgid
def build_unsubscribe_url(email: str, token: str, base_url: str) -> str: def build_unsubscribe_url(email: str, token: str, base_url: str) -> str:
return f"{base_url}/api/v1/email/unsubscribe?token={token}&email={email}" 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( async def create_email_message(
to_email: str, to_email: str,
subject: str, subject: str,
html_content: 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 = MIMEMultipart("alternative")
msg['From'] = f"{FROM_NAME} <{FROM_EMAIL}>" msg["From"] = f"{FROM_NAME} <{FROM_EMAIL}>"
msg['To'] = to_email msg["To"] = to_email
msg['Subject'] = subject msg["Subject"] = subject
msg['Message-ID'] = make_msgid(domain=FROM_EMAIL.split('@')[1]) msg["Message-ID"] = make_msgid(
msg['Date'] = formatdate(localtime=True) domain=FROM_EMAIL.rsplit("@", 1)[1],
msg['X-Priority'] = "3" )
msg['Importance'] = "normal" msg["Date"] = formatdate(localtime=True)
msg['X-Auto-Response-Suppress'] = "All" msg["X-Priority"] = "3"
msg["Importance"] = "normal"
msg["X-Auto-Response-Suppress"] = "All"
# token = await create_unsubscribe_token(to_email, user_id) # Do not send unsubscribe headers to yourself.
unsubscribe_url = build_unsubscribe_url(to_email, None, FULL_DOMAIN) if to_email.lower() != FROM_EMAIL.lower():
if to_email != FROM_EMAIL: unsubscribe_url = build_unsubscribe_url(
msg['List-Unsubscribe'] = f"<{unsubscribe_url}>" to_email=to_email,
msg['List-Unsubscribe-Post'] = "List-Unsubscribe=One-Click" user_id=user_id,
domain=FULL_DOMAIN,
)
msg.attach(MIMEText(html_content, 'html')) msg["List-Unsubscribe"] = f"<{unsubscribe_url}>"
msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
return msg 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): def connect(email_server, email_port):

View File

@ -180,23 +180,42 @@
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "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", " cursor = delivery_collection.aggregate([\n",
" {\"$match\": {\n", " {\n",
" \"prod\": PROD, **_filter\n", " \"$match\": {\n",
" }},\n", " \"prod\": PROD,\n",
" **(_filter or {}),\n",
" }\n",
" },\n",
" {\"$sort\": {\"_id\": -1}},\n", " {\"$sort\": {\"_id\": -1}},\n",
" {\"$group\": {\n", " {\n",
" \"_id\": \"$post_id\",\n", " \"$group\": {\n",
" \"doc\": {\"$first\": \"$$ROOT\"}\n", " \"_id\": \"$post_id\",\n",
" }},\n", " \"doc\": {\"$first\": \"$$ROOT\"},\n",
" }\n",
" },\n",
" {\"$replaceRoot\": {\"newRoot\": \"$doc\"}},\n", " {\"$replaceRoot\": {\"newRoot\": \"$doc\"}},\n",
" {\"$sort\": {\"_id\": -1}},\n", " {\"$sort\": {\"_id\": -1}},\n",
" ])\n", " ])\n",
" if _status:\n", "\n",
" return (doc async for doc in cursor if doc.get('status') == _status)\n", " if _status is None:\n",
" else:\n", " return cursor\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" "display_name": ".venv"
}, },
@ -1689,8 +1708,10 @@
"):\n", "):\n",
" exhibition = await exhibition_collection.find_one({'_id': ObjectId(exhibition_id)})\n", " exhibition = await exhibition_collection.find_one({'_id': ObjectId(exhibition_id)})\n",
" if exhibition:\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", " 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", " return templates.TemplateResponse(\n",
" request=request, name=\"exhibition/index.html\", context={\"id\": 1, \"user\": user, \"exhibition\": exhibition, \"posts\": posts, 'now': datetime.now()}\n", " request=request, name=\"exhibition/index.html\", context={\"id\": 1, \"user\": user, \"exhibition\": exhibition, \"posts\": posts, 'now': datetime.now()}\n",
" )\n", " )\n",
@ -4998,8 +5019,8 @@
" \n", " \n",
" data = [doc async for doc in get_delivery_aggregate(_match)]\n", " data = [doc async for doc in get_delivery_aggregate(_match)]\n",
"\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", " 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')]}}).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", " return templates.TemplateResponse(\n",
" request=request,\n", " request=request,\n",
" name=\"profile/delivery.html\",\n", " name=\"profile/delivery.html\",\n",

51
main.py
View File

@ -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) 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([ cursor = delivery_collection.aggregate([
{"$match": { {
"prod": PROD, **_filter "$match": {
}}, "prod": PROD,
**(_filter or {}),
}
},
{"$sort": {"_id": -1}}, {"$sort": {"_id": -1}},
{"$group": { {
"_id": "$post_id", "$group": {
"doc": {"$first": "$$ROOT"} "_id": "$post_id",
}}, "doc": {"$first": "$$ROOT"},
}
},
{"$replaceRoot": {"newRoot": "$doc"}}, {"$replaceRoot": {"newRoot": "$doc"}},
{"$sort": {"_id": -1}}, {"$sort": {"_id": -1}},
]) ])
if _status:
return (doc async for doc in cursor if doc.get('status') == _status) if _status is None:
else:
return cursor 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]: async def get_latest_news() -> List[dict]:
cursor = news_collection.find({'publish_on_platform': True}).sort("_id", -1).limit(30) 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)}) exhibition = await exhibition_collection.find_one({'_id': ObjectId(exhibition_id)})
if exhibition: 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( return templates.TemplateResponse(
request=request, name="exhibition/index.html", context={"id": 1, "user": user, "exhibition": exhibition, "posts": posts, 'now': datetime.now()} 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)] 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() 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')]}}).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( return templates.TemplateResponse(
request=request, request=request,
name="profile/delivery.html", name="profile/delivery.html",

View File

@ -38,3 +38,4 @@ celery[redis]==5.6.3
pika==1.3.2 pika==1.3.2
pillow==12.1.1 pillow==12.1.1
pytz pytz
python-gnupg

View File

@ -97,7 +97,7 @@
<div class="b_1tx4m"> <div class="b_1tx4m">
<picture data-link="https://kultmost.ru/artists/{{post.user._id}}" data-label="Все картины автора и стоимость"> <picture data-link="https://kultmost.ru/artists/{{post.user._id}}" data-label="Все картины автора и стоимость">
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1tx4m" <img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1tx4m"
src="/b/posts/{{ post.medias[0] }}" data-src="/b/posts/{{ post.medias_original[0] }}"> src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
</picture> </picture>
</div> </div>
<figcaption class="c_fzh03 b_bp0ws"> <figcaption class="c_fzh03 b_bp0ws">

View File

@ -73,7 +73,7 @@
<div class="b_1tx4m"> <div class="b_1tx4m">
<picture> <picture>
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1tx4m" <img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1tx4m"
src="/b/posts/{{ post.medias[0] }}" data-src="/b/posts/{{ post.medias_original[0] }}"> src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
</picture> </picture>
</div> </div>
<figcaption class="c_fzh03 b_bp0ws"> <figcaption class="c_fzh03 b_bp0ws">

View File

@ -92,11 +92,12 @@
<div class="c_cnedr"> <div class="c_cnedr">
{% for doc in posts %} {% for doc in posts %}
{% set post=doc.get('post')%} {% set post=doc.get('post')%}
{% if post %}
<figure class="c_g5532 b_wcxt0"> <figure class="c_g5532 b_wcxt0">
<div class="b_1tx4m"> <div class="b_1tx4m">
<picture data-link="https://kultmost.ru/artists/{{post.user._id}}" data-label="Все картины автора и стоимость"> <picture data-link="https://kultmost.ru/artists/{{post.get('user',{}).get('_id')}}" data-label="Все картины автора и стоимость">
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1tx4m" <img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1tx4m"
src="/b/posts/{{ post.medias[0] }}" data-src="/b/posts/{{ post.medias_original[0] }}"> src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
</picture> </picture>
</div> </div>
<figcaption class="c_fzh03 b_bp0ws"> <figcaption class="c_fzh03 b_bp0ws">
@ -123,6 +124,7 @@
<p class="c_tn4fi b_vjrwu">{{ (post._id.generation_time.strftime('%d.%m.%Y')) if post._id else ''}}</p> <p class="c_tn4fi b_vjrwu">{{ (post._id.generation_time.strftime('%d.%m.%Y')) if post._id else ''}}</p>
</figcaption> </figcaption>
</figure> </figure>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>

View File

@ -4,7 +4,7 @@
<div class="b_1vs97"> <div class="b_1vs97">
<picture> <picture>
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1vs97" <img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1vs97"
src="/b/posts/{{ post.medias[0] }}" data-src="/b/posts/{{ post.medias_original[0] }}"> src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
</picture> </picture>
</div> </div>
<figcaption class="c_dvi1x b_is70q"> <figcaption class="c_dvi1x b_is70q">

View File

@ -17,7 +17,7 @@
<div class="b_thlt1"> <div class="b_thlt1">
<span class="b_5dr47">{{doc.status}}</span> <span class="b_5dr47">{{doc.status}}</span>
</div> </div>
{% elif doc.status == 'Получено' %} {% elif 'Получено' in (doc.status or '') %}
<div class="b_thlt1"> <div class="b_thlt1">
<span class="b_5dr47">{{doc.status}}</span> <span class="b_5dr47">{{doc.status}}</span>
</div> </div>
@ -101,99 +101,115 @@
<div class="c_ru432"> <div class="c_ru432">
{{ profile_nav.profile_nav(user, 'delivery') }} {{ profile_nav.profile_nav(user, 'delivery') }}
<div class="c_tihtk" id="main"> <div class="c_tihtk" id="main">
{% for competition in competitions %} <!-- <details>
<article class="c_9fgdh b_ax8vz" id="l{{competition._id}}"> <summary>Конкурсы</summary>
<a href="/profile/delivery/competition/{{competition._id}}#main"> <div class="c_tihtk"> -->
<div class="b_ornr9"> {% for competition in competitions %}
<picture> <article class="c_9fgdh b_ax8vz" id="l{{competition._id}}">
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ competition.title }}" class="b_ornr9" src="/b/competition/{{ competition.poster_file_id }}"> <a href="/profile/delivery/competition/{{competition._id}}#main">
</picture> <div class="b_ornr9">
</div> <picture>
</a> <img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ competition.title }}" class="b_ornr9"
src="/b/competition/{{ competition.poster_file_id }}">
</picture>
</div>
</a>
<div class="c_z3dpx b_73p10"> <div class="c_z3dpx b_73p10">
<a href="/profile/delivery/competition/{{competition._id}}#main" class="b_73p10"> <a href="/profile/delivery/competition/{{competition._id}}#main" class="b_73p10">
<div class="b_vabj9_2"> <div class="b_vabj9_2">
<span class="b_0jst1_2">Конкурс</span> <span class="b_0jst1_2">Конкурс</span>
</div> </div>
{% if competition.get('status',-1) == 2 %} {% if competition.get('status',-1) == 2 %}
<div class="b_vabj9_2"> <div class="b_vabj9_2">
<span class="b_0jst1_2">Закончился</span> <span class="b_0jst1_2">Закончился</span>
</div> </div>
{% elif competition.get('status',-1) == 1 %} {% elif competition.get('status',-1) == 1 %}
<div class="b_vabj9_2"> <div class="b_vabj9_2">
<span class="b_0jst1_2">Идет отбор</span> <span class="b_0jst1_2">Идет отбор</span>
</div> </div>
{% else %} {% else %}
<div class="b_vabj9"> <div class="b_vabj9">
<span class="b_0jst1">{{ {-1:'Формируется',0:'Прием заявок',1:'Идет отбор',2:'Закончился'}[competition.get('status',-1)] }}</span> <span class="b_0jst1">{{ {-1:'Формируется',0:'Прием заявок',1:'Идет
отбор',2:'Закончился'}[competition.get('status',-1)] }}</span>
</div> </div>
{% endif %} {% endif %}
<header class="c_m876y b_9xtz9"> <header class="c_m876y b_9xtz9">
<h3 class="c_hysjg b_8f3uh" style="color: #222;">{{ competition.title }}</h3> <h3 class="c_hysjg b_8f3uh" style="color: #222;">{{ competition.title }}</h3>
</header> </header>
<p class="c_2fr39 b_tfnz2">{{ competition.topic }}</p> <p class="c_2fr39 b_tfnz2">{{ competition.topic }}</p>
{% if profile %} {% if profile %}
<button class="c_aq9et"> <button class="c_aq9et">
<span class="c_som9s">Посмотреть заявки</span> <span class="c_som9s">Посмотреть заявки</span>
</button> </button>
{% else %} {% else %}
{% set user = competition.get('user') %} {% set user = competition.get('user') %}
<div class="c_dbbe4 b_otttb"> <div class="c_dbbe4 b_otttb">
{% if user %} {% if user %}
<img class="c_pm80w b_a68fq" src="/a/{{user._id}}" alt="{{ user.username | e }}" crossorigin="anonymous" fetchpriority="high" loading="eager"> <img class="c_pm80w b_a68fq" src="/a/{{user._id}}" alt="{{ user.username | e }}" crossorigin="anonymous"
<div class="c_4402p b_ntazj"> fetchpriority="high" loading="eager">
<p class="c_kvah4 b_ghosy">{{ user.username | e }}</p> <div class="c_4402p b_ntazj">
</div> <p class="c_kvah4 b_ghosy">{{ user.username | e }}</p>
{% endif %} </div>
{% endif %}
</div> </div>
<p class="c_2fr39 b_tfnz2">Подробнее</p> <p class="c_2fr39 b_tfnz2">Подробнее</p>
{% endif %} {% endif %}
</a> </a>
<p class="c_drpqj b_mp1wb">{{ (competition.datetime_start.strftime('%d.%m.%Y')) if competition.datetime_start else ''}}</p> <p class="c_drpqj b_mp1wb">{{ (competition.datetime_start.strftime('%d.%m.%Y')) if competition.datetime_start else
''}}</p>
</div> </div>
</article> </article>
{% endfor %} {% endfor %}
{% for i in exhibitions %} <!-- </div>
<article class="c_9fgdh b_ax8vz"> </details>
<a href="/profile/delivery/exhibition/{{i._id}}#main"> <details open>
<div class="b_ornr9"> <summary>Выставки</summary>
<picture> <div class="c_tihtk"> -->
<img src="/b/exhibition/{{i.poster_file_id}}" data-src="/b/exhibition/{{i.poster_orig_id}}" id="preview_poster" crossorigin="anonymous" fetchpriority="high" loading="eager" alt="Загрузите афишу" class="b_ornr9"> {% for i in exhibitions %}
</picture> <article class="c_9fgdh b_ax8vz">
</div> <a href="/profile/delivery/exhibition/{{i._id}}#main">
</a> <div class="b_ornr9">
<div class="c_z3dpx b_73p10"> <picture>
<a class="b_73p10" href="/profile/delivery/exhibition/{{i._id}}#main"> <img src="/b/exhibition/{{i.poster_file_id}}" data-src="/b/exhibition/{{i.poster_orig_id}}" id="preview_poster"
<div class="b_vabj9_2"> crossorigin="anonymous" fetchpriority="high" loading="eager" alt="Загрузите афишу" class="b_ornr9">
<span class="b_0jst1_2">Выставка</span> </picture>
</div> </div>
<div class="c_c4wam b_vabj9"> </a>
{% if now > i.date_finish %} <div class="c_z3dpx b_73p10">
<span class="c_95csi b_0jst1">Закончилась</span> <a class="b_73p10" href="/profile/delivery/exhibition/{{i._id}}#main">
{% elif now > i.datetime_start %} <div class="b_vabj9_2">
<span class="c_95csi b_0jst1">Началась</span> <span class="b_0jst1_2">Выставка</span>
{% else %} </div>
<span class="c_95csi b_0jst1">Формируется</span> <div class="c_c4wam b_vabj9">
{% endif %} {% if now > i.date_finish %}
</div> <span class="c_95csi b_0jst1">Закончилась</span>
<header class="c_m876y b_9xtz9"> {% elif now > i.datetime_start %}
<h3 class="c_hysjg b_8f3uh" id="preview_title" style="color: #222;">{{i.title}}</h3> <span class="c_95csi b_0jst1">Началась</span>
</header> {% else %}
<p class="c_2fr39 b_tfnz2" id="preview_topic">{{i.topic}}</p> <span class="c_95csi b_0jst1">Формируется</span>
<div class="c_dbbe4 b_otttb"> {% endif %}
{% if user %} </div>
<img crossorigin="anonymous" fetchpriority="high" loading="eager" class="c_pm80w b_a68fq" src="/a/{{i.user._id}}" alt="{{ i.user.username | e }}"> <header class="c_m876y b_9xtz9">
<div class="c_4402p b_ntazj"> <h3 class="c_hysjg b_8f3uh" id="preview_title" style="color: #222;">{{i.title}}</h3>
<p class="c_kvah4 b_ghosy">{{ i.user.username }}</p> </header>
</div> <p class="c_2fr39 b_tfnz2" id="preview_topic">{{i.topic}}</p>
{% endif %} <div class="c_dbbe4 b_otttb">
</div> {% if user %}
<p class="c_2fr39 b_tfnz2">Подробнее</p> <img crossorigin="anonymous" fetchpriority="high" loading="eager" class="c_pm80w b_a68fq"
</a> src="/a/{{i.user._id}}" alt="{{ i.user.username | e }}">
<p class="c_drpqj b_mp1wb">{{ ( i._id.generation_time.strftime('%d.%m.%Y')) if i._id else ''}}</p> <div class="c_4402p b_ntazj">
</div> <p class="c_kvah4 b_ghosy">{{ i.user.username }}</p>
</article> </div>
{% endfor %} {% endif %}
</div>
<p class="c_2fr39 b_tfnz2">Подробнее</p>
</a>
<p class="c_drpqj b_mp1wb">{{ ( i._id.generation_time.strftime('%d.%m.%Y')) if i._id else ''}}</p>
</div>
</article>
{% endfor %}
<!-- </div>
</details> -->
</div> </div>
</div> </div>
</div> </div>
@ -208,6 +224,7 @@
{{ profile_nav.style() }} {{ profile_nav.style() }}
{{card.style()}} {{card.style()}}
<style> <style>
summary{ text-align: center; font-size: 24px; color: #5f1798; font-weight: bold; width: 100%; }
/* Base (common) styles */ /* Base (common) styles */
.c_aihm6{width: 100%;} .c_aihm6{width: 100%;}
.mw{margin: 0 20px; .mw{margin: 0 20px;

View File

@ -33,7 +33,7 @@
<div class="b_thlt1"> <div class="b_thlt1">
<span class="b_5dr47">Запрос на отправку</span> <span class="b_5dr47">Запрос на отправку</span>
</div> </div>
{% elif doc.status == 'Получено' %} {% elif 'Получено' in (doc.status or '') %}
<div class="b_thlt1"> <div class="b_thlt1">
<span class="b_5dr47">{{doc.status}}</span> <span class="b_5dr47">{{doc.status}}</span>
</div> </div>

View File

@ -17,7 +17,7 @@
<div class="b_thlt1"> <div class="b_thlt1">
<span class="b_5dr47">{{doc.status}}</span> <span class="b_5dr47">{{doc.status}}</span>
</div> </div>
{% elif doc.status == 'Получено' %} {% elif 'Получено' in (doc.status or '') %}
<div class="b_thlt1"> <div class="b_thlt1">
<span class="b_5dr47">{{doc.status}}</span> <span class="b_5dr47">{{doc.status}}</span>
</div> </div>

View File

@ -63,6 +63,12 @@
<span></span> <span></span>
</div> </div>
<div class="c_nb6gy floating-group">
<label for="gnupg" class="c_z3ybu floating-label">GnuPG</label>
<textarea type="text" id="gnupg" name="gnupg" class="c_4ykpn" ></textarea>
<span>Самозащита электронной почты</span>
</div>
<div class="c_aihm6 floating-group"> <div class="c_aihm6 floating-group">
<label for="phone" class="c_z3ybu floating-label">Телефон<span style="color:red">*</span></label> <label for="phone" class="c_z3ybu floating-label">Телефон<span style="color:red">*</span></label>
<input name="phone" id="phone" class="c_xhd5k" required /> <input name="phone" id="phone" class="c_xhd5k" required />
@ -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('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('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('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('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('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 %}` 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, 'social_media': document.getElementById('social_media').value,
'marketplaces': document.getElementById('marketplaces').value, 'marketplaces': document.getElementById('marketplaces').value,
'email': document.getElementById('email').value, 'email': document.getElementById('email').value,
'gnupg': document.getElementById('gnupg').value,
'phone': document.getElementById('phone').value, 'phone': document.getElementById('phone').value,
'messengers': document.getElementById('messengers').value, 'messengers': document.getElementById('messengers').value,
'specialization': document.getElementById('specialization').value, 'specialization': document.getElementById('specialization').value,

View File

@ -12,21 +12,21 @@
<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}}"> <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}}"> <img class="b_1fvkt" src="/b/posts/{{doc.img}}">
<figcaption class="c_3g8gh b_pvw5a" data-id="{{doc._id}}"> <figcaption class="c_3g8gh b_pvw5a" data-id="{{doc._id}}">
{% if doc.status == 'Ожидает отправки' %} {% if (doc.status or '') == 'Ожидает отправки' %}
<div class="b_thlt1"> <div class="b_thlt1">
<span class="b_5dr47">{{doc.status}}</span> <span class="b_5dr47">{{(doc.status or '')}}</span>
</div> </div>
{% elif doc.status == 'Получено' %} {% elif 'Получено' in (doc.status or '') %}
<div class="b_thlt1"> <div class="b_thlt1">
<span class="b_5dr47">{{doc.status}}</span> <span class="b_5dr47">{{(doc.status or '')}}</span>
</div> </div>
{% elif doc.status == 'Ожидает получения' %} {% elif (doc.status or '') == 'Ожидает получения' %}
<div class="b_thlt1_2"> <div class="b_thlt1_2">
<span class="b_5dr47_2">{{doc.status}}</span> <span class="b_5dr47_2">{{(doc.status or '')}}</span>
</div> </div>
{% else %} {% else %}
<div class="b_thlt1_2"> <div class="b_thlt1_2">
<span class="b_5dr47_2">{{doc.status}}</span> <span class="b_5dr47_2">{{(doc.status or '')}}</span>
</div> </div>
{% endif %} {% endif %}
<a class="b_6spaf" href="/profile/delivery/{{doc.post_id}}#main">Посмотреть историю</a> <a class="b_6spaf" href="/profile/delivery/{{doc.post_id}}#main">Посмотреть историю</a>

View File

@ -148,7 +148,7 @@
<div class="b_1fvkt"> <div class="b_1fvkt">
<picture> <picture>
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" <img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}"
class="b_1fvkt" src="/b/posts/{{ post.medias[0] }}" class="b_1fvkt" src="/b/posts/{{ post.medias_original[0] }}/500/90/80"
data-src="/b/posts/{{ post.medias_original[0] }}"> data-src="/b/posts/{{ post.medias_original[0] }}">
</picture> </picture>
</div> </div>

View File

@ -176,7 +176,7 @@ failBtn.addEventListener('click', () => selectMember('fail'));
<div class="b_1fvkt"> <div class="b_1fvkt">
<picture> <picture>
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1fvkt" <img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1fvkt"
src="/b/posts/{{ post.medias[0] }}" data-src="/b/posts/{{ post.medias_original[0] }}"> src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
</picture> </picture>
</div> </div>
<figcaption class="c_3g8gh b_pvw5a"> <figcaption class="c_3g8gh b_pvw5a">

View File

@ -6,7 +6,7 @@
{% import "competition/list.html" as competition %} {% import "competition/list.html" as competition %}
{% import "components/label.html" as label %} {% import "components/label.html" as label %}
{% block title %}Каталог картин{% endblock %} {% block title %}Каталог картин | Выставка {{exhibition.title}}{% endblock %}
{% block content %} {% block content %}
{{ header.section(user) }} {{ header.section(user) }}
@ -17,7 +17,7 @@
{{ profile_nav.profile_nav(user, 'exhibitions') }} {{ profile_nav.profile_nav(user, 'exhibitions') }}
<div class="c_3ihvi" id="main"> <div class="c_3ihvi" id="main">
<a class="c_ctfod b_289i1 ba" 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> <span class="c_d2l0b b_al8e7">Отобранные картины (<span id="lenPosts2">{{ exhibition.posts | length }}</span>)</span>
</a> </a>
</div> </div>
<div class="c_tihtk"> <div class="c_tihtk">
@ -476,6 +476,8 @@ genre = genres.map(g => {
// Обновляем счётчик // Обновляем счётчик
const lenEl = document.getElementById('lenPosts'); const lenEl = document.getElementById('lenPosts');
if (lenEl) lenEl.textContent = exhibition.posts.length; if (lenEl) lenEl.textContent = exhibition.posts.length;
const lenEl2 = document.getElementById('lenPosts2');
if (lenEl2) lenEl2.textContent = exhibition.posts.length;
} else { } else {
el.checked = !isAdding; el.checked = !isAdding;

View File

@ -7,7 +7,7 @@
{% import "components/label.html" as label %} {% import "components/label.html" as label %}
{% import "/components/contacts.html" as contacts %} {% import "/components/contacts.html" as contacts %}
{% block title %}Отобранные картины | Выставка {{exhibition.title}}{% endblock %} {% block title %}Отправить приглашения | Выставка {{exhibition.title}}{% endblock %}
{% block content %} {% block content %}

View File

@ -2,6 +2,7 @@
{% import "/components/header.html" as header %} {% import "/components/header.html" as header %}
{% import "/components/footer.html" as footer %} {% import "/components/footer.html" as footer %}
{% import "posts/profile_card.html" as card %} {% import "posts/profile_card.html" as card %}
{% import "/components/contacts.html" as contacts %}
{% block title %}Посты{% endblock %} {% block title %}Посты{% endblock %}
@ -14,7 +15,6 @@
<div class="c_gtoe9"> <div class="c_gtoe9">
<div class="c_iiehk"> <div class="c_iiehk">
<div class="c_8xkk0 b_2rmrs"> <div class="c_8xkk0 b_2rmrs">
<div class="b_hjteq"> <div class="b_hjteq">
<div class="b_zo7wz" for="avatar"> <div class="b_zo7wz" for="avatar">
<img class="b_rhmvx" src="/a/{{find._id}}" alt="{{find.username}}" id="preview" crossorigin="anonymous" fetchpriority="high" loading="eager"> <img class="b_rhmvx" src="/a/{{find._id}}" alt="{{find.username}}" id="preview" crossorigin="anonymous" fetchpriority="high" loading="eager">
@ -22,10 +22,35 @@
</div> </div>
<p class="c_kpaav b_xsvl1">{{find.username}}</p> <p class="c_kpaav b_xsvl1">{{find.username}}</p>
{% if 'admin' in user.get('roles', []) %} {% if 'admin' in user.get('roles', []) %}
<div class="c_ctfod b_289i1" id="switch-user"> <div class="c_ctfod b_289i1" id="switch-user">
<button class="c_d2l0b b_al8e7">Переключиться на пользователя</button> <button class="c_d2l0b b_al8e7">Переключиться на пользователя</button>
</div> </div>
{% endif %}
{% if 'admin' in user.get('roles', []) or 'partner' in user.get('roles', []) %}
<!-- <div class="c_ctfod b_289i1"><span class="c_d2l0b b_al8e7">Написать пользователю</span></div> -->
{{ contacts.details(find) }}
{% endif %}
<div >На платформе с {{ (find._id.generation_time.strftime('%Y')) if find._id else ''}}</div>
{% if find.get('grade') is not none%}
<p class="it o">Звание:</p>
<p class="it">{{find.grade}}</p>
{% endif %}
{% if find.get('biography') is not none%}
<p class="it o">Биография:</p>
<p class="it">{{find.biography}}</p>
{% endif %}
<p>👁 {{total_watch_count}} (+{{today_watch_count}} Сегодня)</p>
</div>
</div>
<div class="c_ru432">
<div class="c_tihtk">
{% for i in cards %}
{{ card.section(i, loop.index, user) }}
{% endfor %}
</div>
</div>
</div>
</section>
<script> <script>
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause // @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
const userId = '{{find._id}}'; const userId = '{{find._id}}';
@ -50,33 +75,6 @@ document.getElementById('switch-user').addEventListener('click', async () => {
}); });
// @license-end // @license-end
</script> </script>
{% endif %}
{% if 'admin' in user.get('roles', []) %}
<div class="c_ctfod b_289i1"><span class="c_d2l0b b_al8e7">Написать пользователю</span></div>
{% endif %}
<div >На платформе с {{ (find._id.generation_time.strftime('%Y')) if find._id else ''}}</div>
{% if find.get('grade') is not none%}
<p class="it o">Звание:</p>
<p class="it">{{find.grade}}</p>
{% endif %}
{% if find.get('biography') is not none%}
<p class="it o">Биография:</p>
<p class="it">{{find.biography}}</p>
{% endif %}
<p>👁 {{total_watch_count}} (+{{today_watch_count}} Сегодня)</p>
</div>
</div>
<div class="c_ru432">
<div class="c_tihtk">
{% for i in cards %}
{{ card.section(i, loop.index, user) }}
{% endfor %}
</div>
</div>
</div>
</section>
<script> <script>
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause // @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@ -101,6 +99,7 @@ document.addEventListener('DOMContentLoaded', () => {
{% block head %} {% block head %}
{{ header.style() }} {{ header.style() }}
{{ card.style() }} {{ card.style() }}
{{ contacts.style() }}
<style> <style>
.o{font-style: italic;color: #777;} .o{font-style: italic;color: #777;}
.it{padding: 0px 15px;} .it{padding: 0px 15px;}