Merge branch 'master' into storefront-nuxt
This commit is contained in:
commit
2c4e66832f
31 changed files with 840 additions and 101 deletions
|
|
@ -5,8 +5,7 @@ LABEL authors="fureunoir"
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
LANG=C.UTF-8 \
|
LANG=C.UTF-8 \
|
||||||
DEBIAN_FRONTEND=noninteractive \
|
DEBIAN_FRONTEND=noninteractive
|
||||||
PATH="/root/.local/bin:$PATH"
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -33,18 +32,16 @@ RUN set -eux; \
|
||||||
rm -rf /var/lib/apt/lists/*; \
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh
|
||||||
ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH"
|
|
||||||
|
|
||||||
RUN uv venv /opt/schon-python
|
|
||||||
ENV VIRTUAL_ENV=/opt/schon-python
|
ENV VIRTUAL_ENV=/opt/schon-python
|
||||||
ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python
|
ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python
|
||||||
ENV PATH="/opt/schon-python/bin:/root/.local/bin:/root/.cargo/bin:$PATH"
|
ENV PATH="/opt/schon-python/bin:/usr/local/bin:$PATH"
|
||||||
|
|
||||||
COPY pyproject.toml pyproject.toml
|
COPY pyproject.toml pyproject.toml
|
||||||
COPY uv.lock uv.lock
|
COPY uv.lock uv.lock
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN uv venv /opt/schon-python && \
|
||||||
uv sync --extra worker --extra openai --locked
|
uv sync --extra worker --extra openai --locked
|
||||||
|
|
||||||
COPY ./scripts/Docker/app-entrypoint.sh /usr/local/bin/app-entrypoint.sh
|
COPY ./scripts/Docker/app-entrypoint.sh /usr/local/bin/app-entrypoint.sh
|
||||||
|
|
@ -52,4 +49,11 @@ RUN chmod +x /usr/local/bin/app-entrypoint.sh
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN groupadd --system --gid 1000 schon && \
|
||||||
|
useradd --system --uid 1000 --gid schon --shell /bin/bash --create-home schon && \
|
||||||
|
mkdir -p /app/static /app/media && \
|
||||||
|
chown -R schon:schon /app /opt/schon-python
|
||||||
|
|
||||||
|
USER schon
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/bin/bash", "app-entrypoint.sh"]
|
ENTRYPOINT ["/usr/bin/bash", "app-entrypoint.sh"]
|
||||||
|
|
@ -5,8 +5,7 @@ LABEL authors="fureunoir"
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
LANG=C.UTF-8 \
|
LANG=C.UTF-8 \
|
||||||
DEBIAN_FRONTEND=noninteractive \
|
DEBIAN_FRONTEND=noninteractive
|
||||||
PATH="/root/.local/bin:$PATH"
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -33,17 +32,16 @@ RUN set -eux; \
|
||||||
rm -rf /var/lib/apt/lists/*; \
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh
|
||||||
ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH"
|
|
||||||
RUN uv venv /opt/schon-python
|
|
||||||
ENV VIRTUAL_ENV=/opt/schon-python
|
ENV VIRTUAL_ENV=/opt/schon-python
|
||||||
ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python
|
ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python
|
||||||
ENV PATH="/opt/schon-python/bin:/root/.local/bin:/root/.cargo/bin:$PATH"
|
ENV PATH="/opt/schon-python/bin:/usr/local/bin:$PATH"
|
||||||
|
|
||||||
COPY pyproject.toml pyproject.toml
|
COPY pyproject.toml pyproject.toml
|
||||||
COPY uv.lock uv.lock
|
COPY uv.lock uv.lock
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN uv venv /opt/schon-python && \
|
||||||
uv sync --extra worker --extra openai --locked
|
uv sync --extra worker --extra openai --locked
|
||||||
|
|
||||||
COPY ./scripts/Docker/beat-entrypoint.sh /usr/local/bin/beat-entrypoint.sh
|
COPY ./scripts/Docker/beat-entrypoint.sh /usr/local/bin/beat-entrypoint.sh
|
||||||
|
|
@ -51,4 +49,11 @@ RUN chmod +x /usr/local/bin/beat-entrypoint.sh
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN groupadd --system --gid 1000 schon && \
|
||||||
|
useradd --system --uid 1000 --gid schon --shell /bin/bash --create-home schon && \
|
||||||
|
mkdir -p /app/media && \
|
||||||
|
chown -R schon:schon /app /opt/schon-python
|
||||||
|
|
||||||
|
USER schon
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/bin/bash", "beat-entrypoint.sh"]
|
ENTRYPOINT ["/usr/bin/bash", "beat-entrypoint.sh"]
|
||||||
|
|
@ -5,8 +5,7 @@ LABEL authors="fureunoir"
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
LANG=C.UTF-8 \
|
LANG=C.UTF-8 \
|
||||||
DEBIAN_FRONTEND=noninteractive \
|
DEBIAN_FRONTEND=noninteractive
|
||||||
PATH="/root/.local/bin:$PATH"
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -33,17 +32,16 @@ RUN set -eux; \
|
||||||
rm -rf /var/lib/apt/lists/*; \
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh
|
||||||
ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH"
|
|
||||||
RUN uv venv /opt/schon-python
|
|
||||||
ENV VIRTUAL_ENV=/opt/schon-python
|
ENV VIRTUAL_ENV=/opt/schon-python
|
||||||
ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python
|
ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python
|
||||||
ENV PATH="/opt/schon-python/bin:/root/.local/bin:/root/.cargo/bin:$PATH"
|
ENV PATH="/opt/schon-python/bin:/usr/local/bin:$PATH"
|
||||||
|
|
||||||
COPY pyproject.toml pyproject.toml
|
COPY pyproject.toml pyproject.toml
|
||||||
COPY uv.lock uv.lock
|
COPY uv.lock uv.lock
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN uv venv /opt/schon-python && \
|
||||||
uv sync --extra worker --extra openai --locked
|
uv sync --extra worker --extra openai --locked
|
||||||
|
|
||||||
COPY ./scripts/Docker/stock-updater-entrypoint.sh /usr/local/bin/stock-updater-entrypoint.sh
|
COPY ./scripts/Docker/stock-updater-entrypoint.sh /usr/local/bin/stock-updater-entrypoint.sh
|
||||||
|
|
@ -51,4 +49,11 @@ RUN chmod +x /usr/local/bin/stock-updater-entrypoint.sh
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN groupadd --system --gid 1000 schon && \
|
||||||
|
useradd --system --uid 1000 --gid schon --shell /bin/bash --create-home schon && \
|
||||||
|
mkdir -p /app/media && \
|
||||||
|
chown -R schon:schon /app /opt/schon-python
|
||||||
|
|
||||||
|
USER schon
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/bin/bash", "stock-updater-entrypoint.sh"]
|
ENTRYPOINT ["/usr/bin/bash", "stock-updater-entrypoint.sh"]
|
||||||
|
|
@ -5,8 +5,7 @@ LABEL authors="fureunoir"
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
LANG=C.UTF-8 \
|
LANG=C.UTF-8 \
|
||||||
DEBIAN_FRONTEND=noninteractive \
|
DEBIAN_FRONTEND=noninteractive
|
||||||
PATH="/root/.local/bin:$PATH"
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -33,18 +32,16 @@ RUN set -eux; \
|
||||||
rm -rf /var/lib/apt/lists/*; \
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh
|
||||||
ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH"
|
|
||||||
|
|
||||||
RUN uv venv /opt/schon-python
|
|
||||||
ENV VIRTUAL_ENV=/opt/schon-python
|
ENV VIRTUAL_ENV=/opt/schon-python
|
||||||
ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python
|
ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python
|
||||||
ENV PATH="/opt/schon-python/bin:/root/.local/bin:/root/.cargo/bin:$PATH"
|
ENV PATH="/opt/schon-python/bin:/usr/local/bin:$PATH"
|
||||||
|
|
||||||
COPY pyproject.toml pyproject.toml
|
COPY pyproject.toml pyproject.toml
|
||||||
COPY uv.lock uv.lock
|
COPY uv.lock uv.lock
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN uv venv /opt/schon-python && \
|
||||||
uv sync --extra worker --extra openai --locked
|
uv sync --extra worker --extra openai --locked
|
||||||
|
|
||||||
COPY ./scripts/Docker/worker-entrypoint.sh /usr/local/bin/worker-entrypoint.sh
|
COPY ./scripts/Docker/worker-entrypoint.sh /usr/local/bin/worker-entrypoint.sh
|
||||||
|
|
@ -52,4 +49,11 @@ RUN chmod +x /usr/local/bin/worker-entrypoint.sh
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN groupadd --system --gid 1000 schon && \
|
||||||
|
useradd --system --uid 1000 --gid schon --shell /bin/bash --create-home schon && \
|
||||||
|
mkdir -p /app/media && \
|
||||||
|
chown -R schon:schon /app /opt/schon-python
|
||||||
|
|
||||||
|
USER schon
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/bin/bash", "worker-entrypoint.sh"]
|
ENTRYPOINT ["/usr/bin/bash", "worker-entrypoint.sh"]
|
||||||
|
|
@ -13,6 +13,8 @@ services:
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- static-data:/app/static
|
||||||
|
- media-data:/app/media
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
env_file:
|
env_file:
|
||||||
|
|
@ -131,6 +133,7 @@ services:
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- media-data:/app/media
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -159,6 +162,7 @@ services:
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- media-data:/app/media
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -187,6 +191,7 @@ services:
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- media-data:/app/media
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -245,3 +250,5 @@ volumes:
|
||||||
redis-data:
|
redis-data:
|
||||||
es-data:
|
es-data:
|
||||||
prometheus-data:
|
prometheus-data:
|
||||||
|
static-data:
|
||||||
|
media-data:
|
||||||
|
|
|
||||||
|
|
@ -1046,16 +1046,10 @@ class AddressAdmin(DjangoQLSearchMixin, FieldsetsMixin, GISModelAdmin):
|
||||||
"country",
|
"country",
|
||||||
"user",
|
"user",
|
||||||
)
|
)
|
||||||
list_filter = (
|
# country and region are encrypted — DB-level filtering is not possible
|
||||||
"country",
|
list_filter = ()
|
||||||
"region",
|
# street, city, postal_code are encrypted — DB-level search is not possible
|
||||||
)
|
search_fields = ("user__email",)
|
||||||
search_fields = (
|
|
||||||
"street",
|
|
||||||
"city",
|
|
||||||
"postal_code",
|
|
||||||
"user__email",
|
|
||||||
)
|
|
||||||
readonly_fields = (
|
readonly_fields = (
|
||||||
"uuid",
|
"uuid",
|
||||||
"modified",
|
"modified",
|
||||||
|
|
|
||||||
|
|
@ -750,6 +750,20 @@ PRODUCT_SCHEMA = {
|
||||||
**BASE_ERRORS,
|
**BASE_ERRORS,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
"exact_list": extend_schema(
|
||||||
|
tags=[
|
||||||
|
"products",
|
||||||
|
],
|
||||||
|
summary=_("retrieve exact products by identifier"),
|
||||||
|
description=_(
|
||||||
|
"retrieve a list of products by identifier type (uuid, slug, or sku). "
|
||||||
|
"Send a POST request with `identificator_type` and `identificators` (list of values)."
|
||||||
|
),
|
||||||
|
responses={
|
||||||
|
status.HTTP_200_OK: ProductSimpleSerializer(many=True),
|
||||||
|
**BASE_ERRORS,
|
||||||
|
},
|
||||||
|
),
|
||||||
"seo_meta": extend_schema(
|
"seo_meta": extend_schema(
|
||||||
tags=[
|
tags=[
|
||||||
"products",
|
"products",
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,11 @@ from engine.core.graphene.object_types import (
|
||||||
BulkProductInput,
|
BulkProductInput,
|
||||||
FeedbackType,
|
FeedbackType,
|
||||||
OrderType,
|
OrderType,
|
||||||
|
ProductType,
|
||||||
SearchResultsType,
|
SearchResultsType,
|
||||||
WishlistType,
|
WishlistType,
|
||||||
)
|
)
|
||||||
from engine.core.models import Address, Order, OrderProduct, Wishlist
|
from engine.core.models import Address, Order, OrderProduct, Product, Wishlist
|
||||||
from engine.core.utils import format_attributes, is_url_safe
|
from engine.core.utils import format_attributes, is_url_safe
|
||||||
from engine.core.utils.caching import web_cache
|
from engine.core.utils.caching import web_cache
|
||||||
from engine.core.utils.emailing import contact_us_email
|
from engine.core.utils.emailing import contact_us_email
|
||||||
|
|
@ -574,6 +575,31 @@ class BuyProduct(Mutation):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RetrieveExactProducts(Mutation):
|
||||||
|
class Meta:
|
||||||
|
description = _("retrieve exact products by identificator")
|
||||||
|
|
||||||
|
class Arguments:
|
||||||
|
identificator_type = String(required=True)
|
||||||
|
identificators = List(String, required=True)
|
||||||
|
|
||||||
|
products = List(ProductType, required=True)
|
||||||
|
|
||||||
|
def mutate(self, info, identificator_type: str, identificators: list[str]):
|
||||||
|
match identificator_type:
|
||||||
|
case "uuid":
|
||||||
|
products = Product.objects.filter(uuid__in=identificators)
|
||||||
|
case "slug":
|
||||||
|
products = Product.objects.filter(slug__in=identificators)
|
||||||
|
case "sku":
|
||||||
|
products = Product.objects.filter(sku__in=identificators)
|
||||||
|
case _:
|
||||||
|
raise BadRequest(
|
||||||
|
_("identificator_type must be one of: uuid, slug, sku")
|
||||||
|
)
|
||||||
|
return RetrieveExactProducts(products=products) # ty: ignore[unknown-argument]
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyUnusedLocal,PyTypeChecker
|
# noinspection PyUnusedLocal,PyTypeChecker
|
||||||
class FeedbackProductAction(Mutation):
|
class FeedbackProductAction(Mutation):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
||||||
|
|
@ -99,11 +99,13 @@ class Command(BaseCommand):
|
||||||
def staff_user(self):
|
def staff_user(self):
|
||||||
user, _ = User.objects.get_or_create(
|
user, _ = User.objects.get_or_create(
|
||||||
email=f"staff@{DEMO_EMAIL_DOMAIN}",
|
email=f"staff@{DEMO_EMAIL_DOMAIN}",
|
||||||
first_name="Alice",
|
defaults={
|
||||||
last_name="Schon",
|
"first_name": "Alice",
|
||||||
is_staff=True,
|
"last_name": "Schon",
|
||||||
is_active=True,
|
"is_staff": True,
|
||||||
is_verified=True,
|
"is_active": True,
|
||||||
|
"is_verified": True,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if _:
|
if _:
|
||||||
user.set_password("Staff!Demo888")
|
user.set_password("Staff!Demo888")
|
||||||
|
|
@ -116,12 +118,14 @@ class Command(BaseCommand):
|
||||||
def super_user(self):
|
def super_user(self):
|
||||||
user, _ = User.objects.get_or_create(
|
user, _ = User.objects.get_or_create(
|
||||||
email=f"super@{DEMO_EMAIL_DOMAIN}",
|
email=f"super@{DEMO_EMAIL_DOMAIN}",
|
||||||
first_name="Bob",
|
defaults={
|
||||||
last_name="Schon",
|
"first_name": "Bob",
|
||||||
is_superuser=True,
|
"last_name": "Schon",
|
||||||
is_staff=True,
|
"is_superuser": True,
|
||||||
is_active=True,
|
"is_staff": True,
|
||||||
is_verified=True,
|
"is_active": True,
|
||||||
|
"is_verified": True,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if _:
|
if _:
|
||||||
user.set_password("Super!Demo888")
|
user.set_password("Super!Demo888")
|
||||||
|
|
|
||||||
186
engine/core/migrations/0057_encrypt_address_fields.py
Normal file
186
engine/core/migrations/0057_encrypt_address_fields.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import encrypted_fields.fields
|
||||||
|
from cryptography.fernet import Fernet, MultiFernet
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fernet(settings):
|
||||||
|
keys = []
|
||||||
|
salt_keys = (
|
||||||
|
settings.SALT_KEY
|
||||||
|
if isinstance(settings.SALT_KEY, list)
|
||||||
|
else [settings.SALT_KEY]
|
||||||
|
)
|
||||||
|
for secret_key in [settings.SECRET_KEY] + list(
|
||||||
|
getattr(settings, "SECRET_KEY_FALLBACKS", [])
|
||||||
|
):
|
||||||
|
for salt_key in salt_keys:
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=salt_key.encode("utf-8"),
|
||||||
|
iterations=100_000,
|
||||||
|
backend=default_backend(),
|
||||||
|
)
|
||||||
|
keys.append(
|
||||||
|
base64.urlsafe_b64encode(kdf.derive(secret_key.encode("utf-8")))
|
||||||
|
)
|
||||||
|
return MultiFernet([Fernet(k) for k in keys]) if len(keys) > 1 else Fernet(keys[0])
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_address_fields(apps, schema_editor):
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
f = _make_fernet(settings)
|
||||||
|
|
||||||
|
def enc(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
value = str(value)
|
||||||
|
return f.encrypt(value.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
def enc_json(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
value = json.dumps(value, default=str)
|
||||||
|
return f.encrypt(value.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
with schema_editor.connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT uuid, address_line, street, district, city, region, "
|
||||||
|
"postal_code, country, raw_data, api_response FROM core_address"
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
for (
|
||||||
|
row_id,
|
||||||
|
address_line,
|
||||||
|
street,
|
||||||
|
district,
|
||||||
|
city,
|
||||||
|
region,
|
||||||
|
postal_code,
|
||||||
|
country,
|
||||||
|
raw_data,
|
||||||
|
api_response,
|
||||||
|
) in rows:
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE core_address SET "
|
||||||
|
"address_line=%s, street=%s, district=%s, city=%s, region=%s, "
|
||||||
|
"postal_code=%s, country=%s, raw_data=%s, api_response=%s "
|
||||||
|
"WHERE uuid=%s",
|
||||||
|
[
|
||||||
|
enc(address_line),
|
||||||
|
enc(street),
|
||||||
|
enc(district),
|
||||||
|
enc(city),
|
||||||
|
enc(region),
|
||||||
|
enc(postal_code),
|
||||||
|
enc(country),
|
||||||
|
enc_json(raw_data),
|
||||||
|
enc_json(api_response),
|
||||||
|
row_id,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0056_pastedimage"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Encrypt text fields
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="address",
|
||||||
|
name="address_line",
|
||||||
|
field=encrypted_fields.fields.EncryptedTextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text="address line for the customer",
|
||||||
|
verbose_name="address line",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="address",
|
||||||
|
name="street",
|
||||||
|
field=encrypted_fields.fields.EncryptedCharField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
verbose_name="street",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="address",
|
||||||
|
name="district",
|
||||||
|
field=encrypted_fields.fields.EncryptedCharField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
verbose_name="district",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="address",
|
||||||
|
name="city",
|
||||||
|
field=encrypted_fields.fields.EncryptedCharField(
|
||||||
|
max_length=100,
|
||||||
|
null=True,
|
||||||
|
verbose_name="city",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="address",
|
||||||
|
name="region",
|
||||||
|
field=encrypted_fields.fields.EncryptedCharField(
|
||||||
|
max_length=100,
|
||||||
|
null=True,
|
||||||
|
verbose_name="region",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="address",
|
||||||
|
name="postal_code",
|
||||||
|
field=encrypted_fields.fields.EncryptedCharField(
|
||||||
|
max_length=20,
|
||||||
|
null=True,
|
||||||
|
verbose_name="postal code",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="address",
|
||||||
|
name="country",
|
||||||
|
field=encrypted_fields.fields.EncryptedCharField(
|
||||||
|
max_length=40,
|
||||||
|
null=True,
|
||||||
|
verbose_name="country",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# JSON fields: JSONB → TEXT (encrypted JSON string)
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="address",
|
||||||
|
name="raw_data",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text="full JSON response from geocoder for this address",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="address",
|
||||||
|
name="api_response",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text="stored JSON response from the geocoding service",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Re-encrypt all existing plaintext values
|
||||||
|
migrations.RunPython(encrypt_address_fields, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
|
|
@ -47,6 +47,7 @@ from django.utils.functional import cached_property
|
||||||
from django.utils.http import urlsafe_base64_encode
|
from django.utils.http import urlsafe_base64_encode
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_extensions.db.fields import AutoSlugField
|
from django_extensions.db.fields import AutoSlugField
|
||||||
|
from encrypted_fields.fields import EncryptedCharField, EncryptedTextField
|
||||||
from mptt.fields import TreeForeignKey
|
from mptt.fields import TreeForeignKey
|
||||||
from mptt.models import MPTTModel
|
from mptt.models import MPTTModel
|
||||||
|
|
||||||
|
|
@ -72,6 +73,7 @@ from engine.core.utils.lists import FAILED_STATUSES
|
||||||
from engine.core.utils.markdown import strip_markdown
|
from engine.core.utils.markdown import strip_markdown
|
||||||
from engine.core.validators import validate_category_image_dimensions
|
from engine.core.validators import validate_category_image_dimensions
|
||||||
from engine.payments.models import Transaction
|
from engine.payments.models import Transaction
|
||||||
|
from schon.fields import EncryptedJSONTextField
|
||||||
from schon.utils.misc import create_object
|
from schon.utils.misc import create_object
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -1133,18 +1135,18 @@ class Address(NiceModel):
|
||||||
|
|
||||||
is_publicly_visible = False
|
is_publicly_visible = False
|
||||||
|
|
||||||
address_line = TextField(
|
address_line = EncryptedTextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text=_("address line for the customer"),
|
help_text=_("address line for the customer"),
|
||||||
verbose_name=_("address line"),
|
verbose_name=_("address line"),
|
||||||
)
|
)
|
||||||
street = CharField(_("street"), max_length=255, null=True)
|
street = EncryptedCharField(_("street"), max_length=255, null=True)
|
||||||
district = CharField(_("district"), max_length=255, null=True)
|
district = EncryptedCharField(_("district"), max_length=255, null=True)
|
||||||
city = CharField(_("city"), max_length=100, null=True)
|
city = EncryptedCharField(_("city"), max_length=100, null=True)
|
||||||
region = CharField(_("region"), max_length=100, null=True)
|
region = EncryptedCharField(_("region"), max_length=100, null=True)
|
||||||
postal_code = CharField(_("postal code"), max_length=20, null=True)
|
postal_code = EncryptedCharField(_("postal code"), max_length=20, null=True)
|
||||||
country = CharField(_("country"), max_length=40, null=True)
|
country = EncryptedCharField(_("country"), max_length=40, null=True)
|
||||||
|
|
||||||
location: PointField = PointField(
|
location: PointField = PointField(
|
||||||
geography=True,
|
geography=True,
|
||||||
|
|
@ -1154,13 +1156,13 @@ class Address(NiceModel):
|
||||||
help_text=_("geolocation point: (longitude, latitude)"),
|
help_text=_("geolocation point: (longitude, latitude)"),
|
||||||
)
|
)
|
||||||
|
|
||||||
raw_data = JSONField(
|
raw_data = EncryptedJSONTextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text=_("full JSON response from geocoder for this address"),
|
help_text=_("full JSON response from geocoder for this address"),
|
||||||
)
|
)
|
||||||
|
|
||||||
api_response = JSONField(
|
api_response = EncryptedJSONTextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text=_("stored JSON response from the geocoding service"),
|
help_text=_("stored JSON response from the geocoding service"),
|
||||||
|
|
|
||||||
|
|
@ -107,10 +107,10 @@ class CategoryDetailSerializer(ModelSerializer):
|
||||||
return list(serializer.data)
|
return list(serializer.data)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_min_price(self, obj: Category):
|
def get_min_price(self, obj: Category) -> float:
|
||||||
return obj.min_price
|
return obj.min_price
|
||||||
|
|
||||||
def get_max_price(self, obj: Category):
|
def get_max_price(self, obj: Category) -> float:
|
||||||
return obj.max_price
|
return obj.max_price
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import os
|
||||||
import traceback
|
import traceback
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
from os import getenv
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from constance import config
|
from constance import config
|
||||||
|
|
@ -35,6 +36,7 @@ from django_ratelimit.decorators import ratelimit
|
||||||
from drf_spectacular.utils import extend_schema_view
|
from drf_spectacular.utils import extend_schema_view
|
||||||
from drf_spectacular.views import SpectacularAPIView
|
from drf_spectacular.views import SpectacularAPIView
|
||||||
from graphene_file_upload.django import FileUploadGraphQLView
|
from graphene_file_upload.django import FileUploadGraphQLView
|
||||||
|
from graphql.validation import NoSchemaIntrospectionCustomRule
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.parsers import MultiPartParser
|
from rest_framework.parsers import MultiPartParser
|
||||||
from rest_framework.permissions import AllowAny, IsAdminUser
|
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||||
|
|
@ -85,6 +87,7 @@ from engine.core.utils.commerce import (
|
||||||
from engine.core.utils.emailing import contact_us_email
|
from engine.core.utils.emailing import contact_us_email
|
||||||
from engine.core.utils.languages import get_flag_by_language
|
from engine.core.utils.languages import get_flag_by_language
|
||||||
from engine.payments.serializers import TransactionProcessSerializer
|
from engine.payments.serializers import TransactionProcessSerializer
|
||||||
|
from schon.graphql_validators import QueryDepthLimitRule
|
||||||
from schon.utils.renderers import camelize
|
from schon.utils.renderers import camelize
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -121,7 +124,14 @@ sitemap_detail.__doc__ = _( # ty:ignore[invalid-assignment]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_graphql_validation_rules = [QueryDepthLimitRule]
|
||||||
|
if getenv("GRAPHQL_INTROSPECTION", "").lower() in ("1", "true", "yes"):
|
||||||
|
_graphql_validation_rules.append(NoSchemaIntrospectionCustomRule)
|
||||||
|
|
||||||
|
|
||||||
class CustomGraphQLView(FileUploadGraphQLView):
|
class CustomGraphQLView(FileUploadGraphQLView):
|
||||||
|
validation_rules = tuple(_graphql_validation_rules)
|
||||||
|
|
||||||
def get_context(self, request):
|
def get_context(self, request):
|
||||||
return request
|
return request
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -530,6 +530,34 @@ class ProductViewSet(SchonViewSet):
|
||||||
self.check_object_permissions(self.request, obj)
|
self.check_object_permissions(self.request, obj)
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
@action(detail=False, methods=("POST",), url_path="retrieve-exact")
|
||||||
|
@method_decorator(ratelimit(key="ip", rate="8/s" if not settings.DEBUG else "44/s"))
|
||||||
|
def exact_list(self, request: Request, *args, **kwargs) -> Response:
|
||||||
|
identificator_type = request.data.get("identificator_type")
|
||||||
|
identificators = request.data.get("identificators", [])
|
||||||
|
|
||||||
|
if not identificator_type or not identificators:
|
||||||
|
return Response(
|
||||||
|
{"detail": _("identificator_type and identificators are required")},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
match identificator_type:
|
||||||
|
case "uuid":
|
||||||
|
qs = self.get_queryset().filter(uuid__in=identificators)
|
||||||
|
case "slug":
|
||||||
|
qs = self.get_queryset().filter(slug__in=identificators)
|
||||||
|
case "sku":
|
||||||
|
qs = self.get_queryset().filter(sku__in=identificators)
|
||||||
|
case _:
|
||||||
|
return Response(
|
||||||
|
{"detail": _("identificator_type must be one of: uuid, slug, sku")},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = ProductSimpleSerializer(qs, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
# noinspection PyUnusedLocal
|
# noinspection PyUnusedLocal
|
||||||
@action(detail=True, methods=("GET",), url_path="feedbacks")
|
@action(detail=True, methods=("GET",), url_path="feedbacks")
|
||||||
@method_decorator(ratelimit(key="ip", rate="2/s" if not settings.DEBUG else "44/s"))
|
@method_decorator(ratelimit(key="ip", rate="2/s" if not settings.DEBUG else "44/s"))
|
||||||
|
|
@ -1193,7 +1221,7 @@ class AddressViewSet(SchonViewSet):
|
||||||
filterset_class = AddressFilter
|
filterset_class = AddressFilter
|
||||||
queryset = Address.objects.all()
|
queryset = Address.objects.all()
|
||||||
serializer_class = AddressSerializer
|
serializer_class = AddressSerializer
|
||||||
additional = {"create": "ALLOW", "retrieve": "ALLOW"}
|
additional = {"create": "ALLOW"}
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
if self.action == "create":
|
if self.action == "create":
|
||||||
|
|
@ -1211,15 +1239,6 @@ class AddressViewSet(SchonViewSet):
|
||||||
|
|
||||||
return Address.objects.none()
|
return Address.objects.none()
|
||||||
|
|
||||||
def retrieve(self, request: Request, *args, **kwargs) -> Response:
|
|
||||||
try:
|
|
||||||
address = Address.objects.get(uuid=str(kwargs.get("pk")))
|
|
||||||
return Response(
|
|
||||||
status=status.HTTP_200_OK, data=self.get_serializer(address).data
|
|
||||||
)
|
|
||||||
except Address.DoesNotExist:
|
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
def create(self, request: Request, *args, **kwargs) -> Response:
|
def create(self, request: Request, *args, **kwargs) -> Response:
|
||||||
create_serializer = AddressCreateSerializer(
|
create_serializer = AddressCreateSerializer(
|
||||||
data=request.data, context={"request": request}
|
data=request.data, context={"request": request}
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,8 @@ class UserAdmin(ActivationActionsMixin, BaseUserAdmin, ModelAdmin):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
list_display = ("email", "phone_number", "is_verified", "is_active", "is_staff")
|
list_display = ("email", "phone_number", "is_verified", "is_active", "is_staff")
|
||||||
search_fields = ("email", "phone_number")
|
# phone_number is encrypted — DB-level search is not possible for it
|
||||||
|
search_fields = ("email",)
|
||||||
list_filter = (
|
list_filter = (
|
||||||
"is_verified",
|
"is_verified",
|
||||||
"is_active",
|
"is_active",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import encrypted_fields.fields
|
||||||
|
from cryptography.fernet import Fernet, MultiFernet
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fernet(settings):
|
||||||
|
keys = []
|
||||||
|
salt_keys = (
|
||||||
|
settings.SALT_KEY
|
||||||
|
if isinstance(settings.SALT_KEY, list)
|
||||||
|
else [settings.SALT_KEY]
|
||||||
|
)
|
||||||
|
for secret_key in [settings.SECRET_KEY] + list(
|
||||||
|
getattr(settings, "SECRET_KEY_FALLBACKS", [])
|
||||||
|
):
|
||||||
|
for salt_key in salt_keys:
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=salt_key.encode("utf-8"),
|
||||||
|
iterations=100_000,
|
||||||
|
backend=default_backend(),
|
||||||
|
)
|
||||||
|
keys.append(
|
||||||
|
base64.urlsafe_b64encode(kdf.derive(secret_key.encode("utf-8")))
|
||||||
|
)
|
||||||
|
return MultiFernet([Fernet(k) for k in keys]) if len(keys) > 1 else Fernet(keys[0])
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_user_pii(apps, schema_editor):
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
f = _make_fernet(settings)
|
||||||
|
|
||||||
|
def enc(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
value = str(value)
|
||||||
|
return f.encrypt(value.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
def enc_json(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
# value may be a Python dict (from JSONB) or a JSON string (after TEXT cast)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
value = json.dumps(value, default=str)
|
||||||
|
return f.encrypt(value.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
with schema_editor.connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT uuid, phone_number, first_name, last_name, attributes "
|
||||||
|
"FROM vibes_auth_user"
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
for row_id, phone, first, last, attrs in rows:
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE vibes_auth_user "
|
||||||
|
"SET phone_number=%s, first_name=%s, last_name=%s, attributes=%s "
|
||||||
|
"WHERE uuid=%s",
|
||||||
|
[enc(phone), enc(first), enc(last), enc_json(attrs), row_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"vibes_auth",
|
||||||
|
"0009_delete_emailimage_remove_emailtemplate_html_content_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Add activation token timestamp
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="activation_token_created",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="activation token created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Encrypt phone_number (also drops unique constraint)
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="phone_number",
|
||||||
|
field=encrypted_fields.fields.EncryptedCharField(
|
||||||
|
blank=True,
|
||||||
|
max_length=20,
|
||||||
|
null=True,
|
||||||
|
verbose_name="phone_number",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Encrypt first_name
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="first_name",
|
||||||
|
field=encrypted_fields.fields.EncryptedCharField(
|
||||||
|
blank=True,
|
||||||
|
max_length=150,
|
||||||
|
null=True,
|
||||||
|
verbose_name="first_name",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Encrypt last_name
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="last_name",
|
||||||
|
field=encrypted_fields.fields.EncryptedCharField(
|
||||||
|
blank=True,
|
||||||
|
max_length=150,
|
||||||
|
null=True,
|
||||||
|
verbose_name="last_name",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Encrypt attributes (JSONB → TEXT with JSON serialisation)
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="attributes",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="attributes",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Re-encrypt existing plaintext values using raw SQL
|
||||||
|
migrations.RunPython(encrypt_user_pii, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
|
|
@ -23,6 +23,7 @@ from django.templatetags.static import static
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from encrypted_fields.fields import EncryptedCharField
|
||||||
from rest_framework_simplejwt.token_blacklist.models import (
|
from rest_framework_simplejwt.token_blacklist.models import (
|
||||||
BlacklistedToken as BaseBlacklistedToken,
|
BlacklistedToken as BaseBlacklistedToken,
|
||||||
)
|
)
|
||||||
|
|
@ -35,6 +36,7 @@ from engine.payments.models import Balance
|
||||||
from engine.vibes_auth.choices import SenderType, ThreadStatus
|
from engine.vibes_auth.choices import SenderType, ThreadStatus
|
||||||
from engine.vibes_auth.managers import UserManager
|
from engine.vibes_auth.managers import UserManager
|
||||||
from engine.vibes_auth.validators import validate_phone_number
|
from engine.vibes_auth.validators import validate_phone_number
|
||||||
|
from schon.fields import EncryptedJSONTextField
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser, NiceModel):
|
class User(AbstractUser, NiceModel):
|
||||||
|
|
@ -51,10 +53,9 @@ class User(AbstractUser, NiceModel):
|
||||||
return "users/" + str(self.uuid) + "/" + args[0]
|
return "users/" + str(self.uuid) + "/" + args[0]
|
||||||
|
|
||||||
email = EmailField(_("email"), unique=True, help_text=_("user email address"))
|
email = EmailField(_("email"), unique=True, help_text=_("user email address"))
|
||||||
phone_number = CharField(
|
phone_number = EncryptedCharField(
|
||||||
_("phone_number"),
|
_("phone_number"),
|
||||||
max_length=20,
|
max_length=20,
|
||||||
unique=True,
|
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text=_("user phone number"),
|
help_text=_("user phone number"),
|
||||||
|
|
@ -63,8 +64,12 @@ class User(AbstractUser, NiceModel):
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
username: None = None
|
username: None = None
|
||||||
first_name = CharField(_("first_name"), max_length=150, blank=True, null=True)
|
first_name = EncryptedCharField(
|
||||||
last_name = CharField(_("last_name"), max_length=150, blank=True, null=True)
|
_("first_name"), max_length=150, blank=True, null=True
|
||||||
|
)
|
||||||
|
last_name = EncryptedCharField(
|
||||||
|
_("last_name"), max_length=150, blank=True, null=True
|
||||||
|
)
|
||||||
avatar = ImageField(
|
avatar = ImageField(
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name=_("avatar"),
|
verbose_name=_("avatar"),
|
||||||
|
|
@ -90,6 +95,11 @@ class User(AbstractUser, NiceModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
activation_token = UUIDField(default=uuid4, verbose_name=_("activation token"))
|
activation_token = UUIDField(default=uuid4, verbose_name=_("activation token"))
|
||||||
|
activation_token_created = DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("activation token created"),
|
||||||
|
)
|
||||||
unsubscribe_token = UUIDField(
|
unsubscribe_token = UUIDField(
|
||||||
default=uuid4,
|
default=uuid4,
|
||||||
verbose_name=_("unsubscribe token"),
|
verbose_name=_("unsubscribe token"),
|
||||||
|
|
@ -102,7 +112,7 @@ class User(AbstractUser, NiceModel):
|
||||||
blank=False,
|
blank=False,
|
||||||
max_length=7,
|
max_length=7,
|
||||||
)
|
)
|
||||||
attributes = JSONField(
|
attributes = EncryptedJSONTextField(
|
||||||
verbose_name=_("attributes"), default=dict, blank=True, null=True
|
verbose_name=_("attributes"), default=dict, blank=True, null=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -135,8 +145,25 @@ class User(AbstractUser, NiceModel):
|
||||||
def recently_viewed(self):
|
def recently_viewed(self):
|
||||||
return cache.get(f"user_{self.uuid}_rv", [])
|
return cache.get(f"user_{self.uuid}_rv", [])
|
||||||
|
|
||||||
def check_token(self, token):
|
def save(self, *args, **kwargs):
|
||||||
return str(token) == str(self.activation_token)
|
if self._state.adding and self.activation_token_created is None:
|
||||||
|
self.activation_token_created = timezone.now()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def refresh_activation_token(self) -> None:
|
||||||
|
"""Generate a fresh activation token and update its timestamp."""
|
||||||
|
self.activation_token = uuid4()
|
||||||
|
self.activation_token_created = timezone.now()
|
||||||
|
|
||||||
|
def check_token(self, token) -> bool:
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
if str(token) != str(self.activation_token):
|
||||||
|
return False
|
||||||
|
if self.activation_token_created:
|
||||||
|
if timezone.now() > self.activation_token_created + timedelta(hours=24):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email
|
return self.email
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@ from engine.vibes_auth.serializers import (
|
||||||
MergeRecentlyViewedSerializer,
|
MergeRecentlyViewedSerializer,
|
||||||
UserSerializer,
|
UserSerializer,
|
||||||
)
|
)
|
||||||
from engine.vibes_auth.utils.emailing import send_reset_password_email_task
|
from engine.vibes_auth.utils.emailing import (
|
||||||
|
send_reset_password_email_task,
|
||||||
|
send_verification_email_task,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -130,6 +133,23 @@ class UserViewSet(
|
||||||
ratelimit(key="ip", rate="5/h" if not settings.DEBUG else "888/h")
|
ratelimit(key="ip", rate="5/h" if not settings.DEBUG else "888/h")
|
||||||
)
|
)
|
||||||
def create(self, request: Request, *args, **kwargs) -> Response:
|
def create(self, request: Request, *args, **kwargs) -> Response:
|
||||||
|
email = request.data.get("email")
|
||||||
|
if email:
|
||||||
|
with suppress(User.DoesNotExist):
|
||||||
|
pending = User.objects.get(
|
||||||
|
email=email, is_active=False, is_verified=False
|
||||||
|
)
|
||||||
|
pending.refresh_activation_token()
|
||||||
|
pending.save()
|
||||||
|
send_verification_email_task.delay(user_pk=str(pending.uuid))
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"detail": _(
|
||||||
|
"Account already registered but not yet activated. A new activation email has been sent."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
user = serializer.save()
|
user = serializer.save()
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,14 @@ dependencies = [
|
||||||
"django-dbbackup==5.2.0",
|
"django-dbbackup==5.2.0",
|
||||||
"django-elasticsearch-dsl==8.2",
|
"django-elasticsearch-dsl==8.2",
|
||||||
"django-extensions==4.1",
|
"django-extensions==4.1",
|
||||||
|
"django-fernet-encrypted-fields==0.3.1",
|
||||||
"django-filter==25.2",
|
"django-filter==25.2",
|
||||||
"django-health-check==4.0.6",
|
"django-health-check==4.1.0",
|
||||||
"django-import-export[all]==4.4.0",
|
"django-import-export[all]==4.4.0",
|
||||||
"django-json-widget==2.1.1",
|
"django-json-widget==2.1.1",
|
||||||
"django-model-utils==5.0.0",
|
"django-model-utils==5.0.0",
|
||||||
"django-md-field==0.1.0",
|
"django-md-field==0.1.0",
|
||||||
"django-modeltranslation==0.19.19",
|
"django-modeltranslation==0.20.2",
|
||||||
"django-mptt==0.18.0",
|
"django-mptt==0.18.0",
|
||||||
"django-redis==6.0.0",
|
"django-redis==6.0.0",
|
||||||
"django-ratelimit==4.1.0",
|
"django-ratelimit==4.1.0",
|
||||||
|
|
@ -47,7 +48,7 @@ dependencies = [
|
||||||
"drf-spectacular-websocket==1.3.1",
|
"drf-spectacular-websocket==1.3.1",
|
||||||
"drf-orjson-renderer==1.8.0",
|
"drf-orjson-renderer==1.8.0",
|
||||||
"elasticsearch-dsl==8.18.0",
|
"elasticsearch-dsl==8.18.0",
|
||||||
"filelock==3.24.3",
|
"filelock==3.25.0",
|
||||||
"filetype==1.2.0",
|
"filetype==1.2.0",
|
||||||
"graphene-django==3.2.3",
|
"graphene-django==3.2.3",
|
||||||
"graphene-file-upload==1.3.0",
|
"graphene-file-upload==1.3.0",
|
||||||
|
|
|
||||||
43
schon/fields.py
Normal file
43
schon/fields.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from encrypted_fields.fields import EncryptedTextField
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptedJSONTextField(EncryptedTextField):
|
||||||
|
"""
|
||||||
|
Stores a JSON-serializable value as Fernet-encrypted TEXT.
|
||||||
|
|
||||||
|
Unlike EncryptedJSONField (which uses JSONB and breaks with psycopg3's
|
||||||
|
automatic JSONB→dict conversion), this field stores the JSON as a plain
|
||||||
|
TEXT column, encrypting the full serialised string. The column type in
|
||||||
|
the DB is text, not jsonb.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_internal_type(self) -> str:
|
||||||
|
return "TextField"
|
||||||
|
|
||||||
|
def get_prep_value(self, value):
|
||||||
|
if value is not None and not isinstance(value, str):
|
||||||
|
value = json.dumps(value, default=str)
|
||||||
|
return super().get_prep_value(value)
|
||||||
|
|
||||||
|
def from_db_value(self, value, expression, connection):
|
||||||
|
value = super().from_db_value(value, expression, connection)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return value
|
||||||
|
|
||||||
|
def to_python(self, value):
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return value
|
||||||
56
schon/graphql_validators.py
Normal file
56
schon/graphql_validators.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
from graphql import GraphQLError
|
||||||
|
from graphql.language.ast import (
|
||||||
|
FieldNode,
|
||||||
|
FragmentDefinitionNode,
|
||||||
|
FragmentSpreadNode,
|
||||||
|
InlineFragmentNode,
|
||||||
|
OperationDefinitionNode,
|
||||||
|
)
|
||||||
|
from graphql.validation import ValidationRule
|
||||||
|
|
||||||
|
MAX_QUERY_DEPTH = 8
|
||||||
|
|
||||||
|
|
||||||
|
def _max_field_depth(node, fragments, depth=0):
|
||||||
|
if not hasattr(node, "selection_set") or not node.selection_set:
|
||||||
|
return depth
|
||||||
|
return max(
|
||||||
|
(
|
||||||
|
_selection_depth(sel, fragments, depth)
|
||||||
|
for sel in node.selection_set.selections
|
||||||
|
),
|
||||||
|
default=depth,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _selection_depth(node, fragments, depth):
|
||||||
|
if isinstance(node, FieldNode):
|
||||||
|
return _max_field_depth(node, fragments, depth + 1)
|
||||||
|
if isinstance(node, InlineFragmentNode):
|
||||||
|
return _max_field_depth(node, fragments, depth)
|
||||||
|
if isinstance(node, FragmentSpreadNode):
|
||||||
|
fragment = fragments.get(node.name.value)
|
||||||
|
if fragment:
|
||||||
|
return _max_field_depth(fragment, fragments, depth)
|
||||||
|
return depth
|
||||||
|
|
||||||
|
|
||||||
|
class QueryDepthLimitRule(ValidationRule):
|
||||||
|
"""Prevents DoS via deeply nested GraphQL queries (max depth: 8)."""
|
||||||
|
|
||||||
|
def enter_document(self, node, *_args):
|
||||||
|
fragments = {
|
||||||
|
defn.name.value: defn
|
||||||
|
for defn in node.definitions
|
||||||
|
if isinstance(defn, FragmentDefinitionNode)
|
||||||
|
}
|
||||||
|
for defn in node.definitions:
|
||||||
|
if not isinstance(defn, OperationDefinitionNode):
|
||||||
|
continue
|
||||||
|
depth = _max_field_depth(defn, fragments)
|
||||||
|
if depth > MAX_QUERY_DEPTH:
|
||||||
|
self.report_error(
|
||||||
|
GraphQLError(
|
||||||
|
f"Query depth limit exceeded: max {MAX_QUERY_DEPTH}, got {depth}."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -20,6 +20,7 @@ BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
|
||||||
INITIALIZED: bool = (BASE_DIR / ".initialized").exists()
|
INITIALIZED: bool = (BASE_DIR / ".initialized").exists()
|
||||||
|
|
||||||
SECRET_KEY: str = getenv("SECRET_KEY", "SUPER_SECRET_KEY")
|
SECRET_KEY: str = getenv("SECRET_KEY", "SUPER_SECRET_KEY")
|
||||||
|
SALT_KEY: str = getenv("SALT_KEY", "schon-default-salt-key-change-in-production")
|
||||||
DEBUG: bool = bool(int(getenv("DEBUG", "1")))
|
DEBUG: bool = bool(int(getenv("DEBUG", "1")))
|
||||||
DEBUG_DATABASE: bool = bool(int(getenv("DEBUG_DATABASE", "0")))
|
DEBUG_DATABASE: bool = bool(int(getenv("DEBUG_DATABASE", "0")))
|
||||||
DEBUG_CELERY: bool = bool(int(getenv("DEBUG_DATABASE", "0")))
|
DEBUG_CELERY: bool = bool(int(getenv("DEBUG_DATABASE", "0")))
|
||||||
|
|
@ -422,8 +423,24 @@ if getenv("SENTRY_DSN"):
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
# noinspection PyShadowingNames
|
# noinspection PyShadowingNames
|
||||||
cleaned: dict[str, Any] = {}
|
cleaned: dict[str, Any] = {}
|
||||||
|
_SENSITIVE_KEYS = {
|
||||||
|
"password",
|
||||||
|
"confirm_password",
|
||||||
|
"phone_number",
|
||||||
|
"phone",
|
||||||
|
"email",
|
||||||
|
"street",
|
||||||
|
"postal_code",
|
||||||
|
"postal",
|
||||||
|
"passport",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
"address",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
}
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if key.lower() in ("password", "confirm_password"):
|
if key.lower() in _SENSITIVE_KEYS:
|
||||||
cleaned[key] = "[FILTERED]"
|
cleaned[key] = "[FILTERED]"
|
||||||
else:
|
else:
|
||||||
cleaned[key] = scrub_sensitive(value)
|
cleaned[key] = scrub_sensitive(value)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,14 @@ CONSTANCE_ADDITIONAL_FIELDS = {
|
||||||
"widget": "engine.core.widgets.JSONTableWidget",
|
"widget": "engine.core.widgets.JSONTableWidget",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"password": [
|
||||||
|
"django.forms.CharField",
|
||||||
|
{
|
||||||
|
"required": False,
|
||||||
|
"widget": "django.forms.PasswordInput",
|
||||||
|
"widget_attrs": {"render_value": True},
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
CONSTANCE_CONFIG = OrderedDict(
|
CONSTANCE_CONFIG = OrderedDict(
|
||||||
|
|
@ -67,7 +75,11 @@ CONSTANCE_CONFIG = OrderedDict(
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"EMAIL_HOST_PASSWORD",
|
"EMAIL_HOST_PASSWORD",
|
||||||
(getenv("EMAIL_HOST_PASSWORD", "SUPERsecretPASSWORD"), _("SMTP password")),
|
(
|
||||||
|
getenv("EMAIL_HOST_PASSWORD", "SUPERsecretPASSWORD"),
|
||||||
|
_("SMTP password"),
|
||||||
|
"password",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
("EMAIL_FROM", (getenv("EMAIL_FROM", "Schon"), _("Mail from option"))),
|
("EMAIL_FROM", (getenv("EMAIL_FROM", "Schon"), _("Mail from option"))),
|
||||||
### Features Options ###
|
### Features Options ###
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,14 @@ REST_FRAMEWORK: dict[str, Any] = {
|
||||||
"DEFAULT_PARSER_CLASSES": ("schon.utils.parsers.CamelCaseParser",),
|
"DEFAULT_PARSER_CLASSES": ("schon.utils.parsers.CamelCaseParser",),
|
||||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.generators.AutoSchema",
|
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.generators.AutoSchema",
|
||||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.AllowAny",),
|
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.AllowAny",),
|
||||||
|
"DEFAULT_THROTTLE_CLASSES": [
|
||||||
|
"rest_framework.throttling.AnonRateThrottle",
|
||||||
|
"rest_framework.throttling.UserRateThrottle",
|
||||||
|
],
|
||||||
|
"DEFAULT_THROTTLE_RATES": {
|
||||||
|
"anon": "60/minute",
|
||||||
|
"user": "600/minute",
|
||||||
|
},
|
||||||
"JSON_UNDERSCOREIZE": {
|
"JSON_UNDERSCOREIZE": {
|
||||||
"no_underscore_before_number": False,
|
"no_underscore_before_number": False,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ SCHON_LANGUAGE_CODE=$(prompt_default SCHON_LANGUAGE_CODE "en-gb")
|
||||||
|
|
||||||
SECRET_KEY=$(prompt_autogen SECRET_KEY 32)
|
SECRET_KEY=$(prompt_autogen SECRET_KEY 32)
|
||||||
JWT_SIGNING_KEY=$(prompt_autogen JWT_SIGNING_KEY 64)
|
JWT_SIGNING_KEY=$(prompt_autogen JWT_SIGNING_KEY 64)
|
||||||
|
SALT_KEY=$(prompt_autogen SALT_KEY 32)
|
||||||
|
|
||||||
ALLOWED_HOSTS=$(prompt_default ALLOWED_HOSTS "schon.wiseless.xyz api.schon.wiseless.xyz")
|
ALLOWED_HOSTS=$(prompt_default ALLOWED_HOSTS "schon.wiseless.xyz api.schon.wiseless.xyz")
|
||||||
CSRF_TRUSTED_ORIGINS=$(prompt_default CSRF_TRUSTED_ORIGINS "https://schon.wiseless.xyz https://api.schon.wiseless.xyz https://www.schon.wiseless.xyz")
|
CSRF_TRUSTED_ORIGINS=$(prompt_default CSRF_TRUSTED_ORIGINS "https://schon.wiseless.xyz https://api.schon.wiseless.xyz https://www.schon.wiseless.xyz")
|
||||||
|
|
@ -101,6 +102,7 @@ SCHON_LANGUAGE_CODE="${SCHON_LANGUAGE_CODE}"
|
||||||
|
|
||||||
SECRET_KEY="${SECRET_KEY}"
|
SECRET_KEY="${SECRET_KEY}"
|
||||||
JWT_SIGNING_KEY="${JWT_SIGNING_KEY}"
|
JWT_SIGNING_KEY="${JWT_SIGNING_KEY}"
|
||||||
|
SALT_KEY="${SALT_KEY}"
|
||||||
|
|
||||||
ALLOWED_HOSTS="${ALLOWED_HOSTS}"
|
ALLOWED_HOSTS="${ALLOWED_HOSTS}"
|
||||||
CSRF_TRUSTED_ORIGINS="${CSRF_TRUSTED_ORIGINS}"
|
CSRF_TRUSTED_ORIGINS="${CSRF_TRUSTED_ORIGINS}"
|
||||||
|
|
|
||||||
48
scripts/Unix/migrate-media.sh
Normal file
48
scripts/Unix/migrate-media.sh
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# migrate-media.sh
|
||||||
|
# Migrates user-uploaded media files from host bind-mount (eVibes / early Schon)
|
||||||
|
# into the new Docker-managed named volume (media-data).
|
||||||
|
#
|
||||||
|
# Run this ONCE after upgrading from an eVibes or pre-volume Schon instance.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST_MEDIA="$(pwd)/media"
|
||||||
|
|
||||||
|
echo "Schon — media migration from host bind-mount → named volume"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ ! -d "$HOST_MEDIA" ]; then
|
||||||
|
echo "No ./media directory found on host. Nothing to migrate."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
FILE_COUNT=$(find "$HOST_MEDIA" -type f | wc -l | tr -d ' ')
|
||||||
|
if [ "$FILE_COUNT" -eq 0 ]; then
|
||||||
|
echo "Host ./media directory is empty. Nothing to migrate."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found $FILE_COUNT file(s) in $HOST_MEDIA"
|
||||||
|
echo ""
|
||||||
|
echo "This will copy them into the Docker named volume 'media-data'."
|
||||||
|
read -rp "Continue? [y/N] " confirm
|
||||||
|
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
||||||
|
echo "Migration cancelled."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Copying files..."
|
||||||
|
docker compose run --rm \
|
||||||
|
-v "$HOST_MEDIA":/old_media:ro \
|
||||||
|
app bash -c "
|
||||||
|
cp -a /old_media/. /app/media/
|
||||||
|
COUNT=\$(find /app/media -type f | wc -l)
|
||||||
|
echo \"Migration complete: \$COUNT file(s) now in media volume.\"
|
||||||
|
"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done. You can safely ignore the host ./media directory — it is no longer used."
|
||||||
|
echo "To remove it: rm -rf ./media"
|
||||||
|
|
@ -13,6 +13,10 @@ log_success "Services were shut down successfully!"
|
||||||
|
|
||||||
# Remove volumes
|
# Remove volumes
|
||||||
log_step "Removing volumes..."
|
log_step "Removing volumes..."
|
||||||
|
docker volume remove -f schon_postgres-data || log_warning "Failed to remove postgres-data volume"
|
||||||
|
docker volume remove -f schon_redis-data || log_warning "Failed to remove redis-data volume"
|
||||||
|
docker volume remove -f schon_static-data || log_warning "Failed to remove static-data volume"
|
||||||
|
docker volume remove -f schon_media-data || log_warning "Failed to remove media-data volume"
|
||||||
docker volume rm -f schon_prometheus-data || log_warning "Failed to remove prometheus-data volume"
|
docker volume rm -f schon_prometheus-data || log_warning "Failed to remove prometheus-data volume"
|
||||||
docker volume rm -f schon_es-data || log_warning "Failed to remove es-data volume"
|
docker volume rm -f schon_es-data || log_warning "Failed to remove es-data volume"
|
||||||
log_success "Volumes were removed successfully!"
|
log_success "Volumes were removed successfully!"
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ $SCHON_LANGUAGE_CODE = Prompt-Default 'SCHON_LANGUAGE_CODE' 'en-gb'
|
||||||
|
|
||||||
$SECRET_KEY = Prompt-AutoGen 'SECRET_KEY' 32
|
$SECRET_KEY = Prompt-AutoGen 'SECRET_KEY' 32
|
||||||
$JWT_SIGNING_KEY = Prompt-AutoGen 'JWT_SIGNING_KEY' 64
|
$JWT_SIGNING_KEY = Prompt-AutoGen 'JWT_SIGNING_KEY' 64
|
||||||
|
$SALT_KEY = Prompt-AutoGen 'SALT_KEY' 32
|
||||||
|
|
||||||
$ALLOWED_HOSTS = Prompt-Default 'ALLOWED_HOSTS' 'schon.wiseless.xyz api.schon.wiseless.xyz'
|
$ALLOWED_HOSTS = Prompt-Default 'ALLOWED_HOSTS' 'schon.wiseless.xyz api.schon.wiseless.xyz'
|
||||||
$CSRF_TRUSTED_ORIGINS = Prompt-Default 'CSRF_TRUSTED_ORIGINS' 'https://schon.wiseless.xyz https://api.schon.wiseless.xyz https://www.schon.wiseless.xyz'
|
$CSRF_TRUSTED_ORIGINS = Prompt-Default 'CSRF_TRUSTED_ORIGINS' 'https://schon.wiseless.xyz https://api.schon.wiseless.xyz https://www.schon.wiseless.xyz'
|
||||||
|
|
@ -108,6 +109,7 @@ $lines = @(
|
||||||
""
|
""
|
||||||
"SECRET_KEY=""$SECRET_KEY"""
|
"SECRET_KEY=""$SECRET_KEY"""
|
||||||
"JWT_SIGNING_KEY=""$JWT_SIGNING_KEY"""
|
"JWT_SIGNING_KEY=""$JWT_SIGNING_KEY"""
|
||||||
|
"SALT_KEY=""$SALT_KEY"""
|
||||||
""
|
""
|
||||||
"ALLOWED_HOSTS=""$ALLOWED_HOSTS"""
|
"ALLOWED_HOSTS=""$ALLOWED_HOSTS"""
|
||||||
"CSRF_TRUSTED_ORIGINS=""$CSRF_TRUSTED_ORIGINS"""
|
"CSRF_TRUSTED_ORIGINS=""$CSRF_TRUSTED_ORIGINS"""
|
||||||
|
|
|
||||||
51
scripts/Windows/migrate-media.ps1
Normal file
51
scripts/Windows/migrate-media.ps1
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# =============================================================================
|
||||||
|
# migrate-media.ps1
|
||||||
|
# Migrates user-uploaded media files from host bind-mount (eVibes / early Schon)
|
||||||
|
# into the new Docker-managed named volume (media-data).
|
||||||
|
#
|
||||||
|
# Run this ONCE after upgrading from an eVibes or pre-volume Schon instance.
|
||||||
|
# =============================================================================
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$HostMedia = Join-Path (Get-Location) "media"
|
||||||
|
|
||||||
|
Write-Host "Schon - media migration from host bind-mount to named volume" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
if (-not (Test-Path $HostMedia)) {
|
||||||
|
Write-Host "No .\media directory found on host. Nothing to migrate." -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$FileCount = (Get-ChildItem -Path $HostMedia -Recurse -File).Count
|
||||||
|
if ($FileCount -eq 0) {
|
||||||
|
Write-Host "Host .\media directory is empty. Nothing to migrate." -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Found $FileCount file(s) in $HostMedia" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "This will copy them into the Docker named volume 'media-data'." -ForegroundColor White
|
||||||
|
$confirm = Read-Host "Continue? [y/N]"
|
||||||
|
if ($confirm -ne "y" -and $confirm -ne "Y") {
|
||||||
|
Write-Host "Migration cancelled." -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Copying files..." -ForegroundColor White
|
||||||
|
|
||||||
|
$HostMediaUnix = $HostMedia -replace '\\', '/' -replace '^([A-Za-z]):', { "/$(($_.Value[0]).ToString().ToLower())" }
|
||||||
|
|
||||||
|
docker compose run --rm `
|
||||||
|
-v "${HostMediaUnix}:/old_media:ro" `
|
||||||
|
app bash -c @"
|
||||||
|
cp -a /old_media/. /app/media/
|
||||||
|
COUNT=`$(find /app/media -type f | wc -l)
|
||||||
|
echo "Migration complete: `$COUNT file(s) now in media volume."
|
||||||
|
"@
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Done. You can safely ignore the host .\media directory - it is no longer used." -ForegroundColor Green
|
||||||
|
Write-Host "To remove it: Remove-Item -Recurse -Force .\media" -ForegroundColor Gray
|
||||||
|
|
@ -21,6 +21,22 @@ Write-Success "Services were shut down successfully!"
|
||||||
|
|
||||||
# Remove volumes
|
# Remove volumes
|
||||||
Write-Step "Removing volumes..."
|
Write-Step "Removing volumes..."
|
||||||
|
docker volume remove -f schon_postgres-data
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Warning-Custom "Failed to remove postgres-data volume"
|
||||||
|
}
|
||||||
|
docker volume remove -f schon_redis-data
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Warning-Custom "Failed to remove redis-data volume"
|
||||||
|
}
|
||||||
|
docker volume remove -f schon_static-data
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Warning-Custom "Failed to remove static-data volume"
|
||||||
|
}
|
||||||
|
docker volume remove -f schon_media-data
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Warning-Custom "Failed to remove media-data volume"
|
||||||
|
}
|
||||||
docker volume remove -f schon_prometheus-data
|
docker volume remove -f schon_prometheus-data
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Warning-Custom "Failed to remove prometheus-data volume"
|
Write-Warning-Custom "Failed to remove prometheus-data volume"
|
||||||
|
|
|
||||||
39
uv.lock
39
uv.lock
|
|
@ -866,6 +866,19 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-fernet-encrypted-fields"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cryptography" },
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1a/aa/529af3888215b8a660fc3897d6d63eaf1de9aa0699c633ca0ec483d4361c/django_fernet_encrypted_fields-0.3.1.tar.gz", hash = "sha256:5ed328c7f9cc7f2d452bb2e125f3ea2bea3563a259fa943e5a1c626175889a71", size = 5265, upload-time = "2025-11-10T08:39:57.398Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/7f/4e0b7ed8413fa58e7a77017342e8ab0e977d41cfc376ab9180ae75f216ec/django_fernet_encrypted_fields-0.3.1-py3-none-any.whl", hash = "sha256:3bd2abab02556dc6e15a58a61161ee6c5cdf45a50a8a52d9e035009eb54c6442", size = 5484, upload-time = "2025-11-10T08:39:55.866Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-filter"
|
name = "django-filter"
|
||||||
version = "25.2"
|
version = "25.2"
|
||||||
|
|
@ -880,15 +893,15 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-health-check"
|
name = "django-health-check"
|
||||||
version = "4.0.6"
|
version = "4.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
{ name = "dnspython" },
|
{ name = "dnspython" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/92/fe/718725c58fd177cff0cfb8abe3010f2cad582713f2bc52eaf7120b750dec/django_health_check-4.0.6.tar.gz", hash = "sha256:03837041ba8a235e810e16218f2ef3feb372c4af72776fa3676c16435c72171c", size = 20763, upload-time = "2026-02-23T17:11:40.625Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f8/e6/37aefb657a522c410844d7fe617c997036ef570157905518c2bf03abfb8d/django_health_check-4.1.0.tar.gz", hash = "sha256:7c9ea01edad40b3ea1d9b5780fcec3fdc1e263f9d0c8503af3b043e6a4b38964", size = 21124, upload-time = "2026-03-01T16:03:10.728Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/44/2fa6ec47c1c18159c094f7d00397a208b6311e8b26d603dd22ba6e79b99d/django_health_check-4.0.6-py3-none-any.whl", hash = "sha256:efba106bc4f92b1b084f3af751e9eeb0b5c1af77d0af212e432ede2ba8f1e94f", size = 25813, upload-time = "2026-02-23T17:11:39.419Z" },
|
{ url = "https://files.pythonhosted.org/packages/a2/01/a2a8e5250c0bf352660208bf62dd6bdc67d3c52c16a412da9c382b0bfd12/django_health_check-4.1.0-py3-none-any.whl", hash = "sha256:a823ec81aa108909855159fa1fdb84d443d0c25057de4cc98bfe230ae52d0751", size = 26173, upload-time = "2026-03-01T16:03:09.03Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -957,15 +970,15 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-modeltranslation"
|
name = "django-modeltranslation"
|
||||||
version = "0.19.19"
|
version = "0.20.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c1/af/3c34fca94fccae681a15734bc6557cf9be958d1d063ddbb233580b894054/django_modeltranslation-0.19.19.tar.gz", hash = "sha256:26dd8454f19540a2eb05e303608a2d89dd80aacb75ab95f8ea272cf4324d2644", size = 77750, upload-time = "2025-12-15T10:25:38.112Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/62/aa/3492ab6250e0c87e26f2f4fd522e4c9b214da871bfe4089a13888a802c6e/django_modeltranslation-0.20.2.tar.gz", hash = "sha256:d687bdcae9305dcdc8d644b75f1d687f355073e82049f4b9c4ba0feb347a0980", size = 81589, upload-time = "2026-03-01T15:59:07.262Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/2a/fdf265e91e37ee363b2c45f3d2a01752a3b38ab082a3ec0b0677105bd367/django_modeltranslation-0.19.19-py3-none-any.whl", hash = "sha256:55ac2ce47486b9e8ca18b155f7705170a53b7e1346bf7bf89304e99787486e8f", size = 93441, upload-time = "2025-12-15T10:25:35.942Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/46/6c1edcab4233baa02cc6cfaf43e01ccc0c28757f9450171e54d790512e21/django_modeltranslation-0.20.2-py3-none-any.whl", hash = "sha256:964385c3cf3a584f564e995f2e9ff0e961d0297ef04ff51821ac529e12064290", size = 98257, upload-time = "2026-03-01T15:59:06.09Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1333,11 +1346,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.24.3"
|
version = "3.25.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3352,6 +3365,7 @@ dependencies = [
|
||||||
{ name = "django-debug-toolbar" },
|
{ name = "django-debug-toolbar" },
|
||||||
{ name = "django-elasticsearch-dsl" },
|
{ name = "django-elasticsearch-dsl" },
|
||||||
{ name = "django-extensions" },
|
{ name = "django-extensions" },
|
||||||
|
{ name = "django-fernet-encrypted-fields" },
|
||||||
{ name = "django-filter" },
|
{ name = "django-filter" },
|
||||||
{ name = "django-health-check" },
|
{ name = "django-health-check" },
|
||||||
{ name = "django-import-export", extra = ["all"] },
|
{ name = "django-import-export", extra = ["all"] },
|
||||||
|
|
@ -3454,13 +3468,14 @@ requires-dist = [
|
||||||
{ name = "django-debug-toolbar", specifier = "==6.2.0" },
|
{ name = "django-debug-toolbar", specifier = "==6.2.0" },
|
||||||
{ name = "django-elasticsearch-dsl", specifier = "==8.2" },
|
{ name = "django-elasticsearch-dsl", specifier = "==8.2" },
|
||||||
{ name = "django-extensions", specifier = "==4.1" },
|
{ name = "django-extensions", specifier = "==4.1" },
|
||||||
|
{ name = "django-fernet-encrypted-fields", specifier = "==0.3.1" },
|
||||||
{ name = "django-filter", specifier = "==25.2" },
|
{ name = "django-filter", specifier = "==25.2" },
|
||||||
{ name = "django-health-check", specifier = "==4.0.6" },
|
{ name = "django-health-check", specifier = "==4.1.0" },
|
||||||
{ name = "django-import-export", extras = ["all"], specifier = "==4.4.0" },
|
{ name = "django-import-export", extras = ["all"], specifier = "==4.4.0" },
|
||||||
{ name = "django-json-widget", specifier = "==2.1.1" },
|
{ name = "django-json-widget", specifier = "==2.1.1" },
|
||||||
{ name = "django-md-field", specifier = "==0.1.0" },
|
{ name = "django-md-field", specifier = "==0.1.0" },
|
||||||
{ name = "django-model-utils", specifier = "==5.0.0" },
|
{ name = "django-model-utils", specifier = "==5.0.0" },
|
||||||
{ name = "django-modeltranslation", specifier = "==0.19.19" },
|
{ name = "django-modeltranslation", specifier = "==0.20.2" },
|
||||||
{ name = "django-mptt", specifier = "==0.18.0" },
|
{ name = "django-mptt", specifier = "==0.18.0" },
|
||||||
{ name = "django-ratelimit", specifier = "==4.1.0" },
|
{ name = "django-ratelimit", specifier = "==4.1.0" },
|
||||||
{ name = "django-redis", specifier = "==6.0.0" },
|
{ name = "django-redis", specifier = "==6.0.0" },
|
||||||
|
|
@ -3481,7 +3496,7 @@ requires-dist = [
|
||||||
{ name = "drf-spectacular", specifier = "==0.29.0" },
|
{ name = "drf-spectacular", specifier = "==0.29.0" },
|
||||||
{ name = "drf-spectacular-websocket", specifier = "==1.3.1" },
|
{ name = "drf-spectacular-websocket", specifier = "==1.3.1" },
|
||||||
{ name = "elasticsearch-dsl", specifier = "==8.18.0" },
|
{ name = "elasticsearch-dsl", specifier = "==8.18.0" },
|
||||||
{ name = "filelock", specifier = "==3.24.3" },
|
{ name = "filelock", specifier = "==3.25.0" },
|
||||||
{ name = "filetype", specifier = "==1.2.0" },
|
{ name = "filetype", specifier = "==1.2.0" },
|
||||||
{ name = "graphene-django", specifier = "==3.2.3" },
|
{ name = "graphene-django", specifier = "==3.2.3" },
|
||||||
{ name = "graphene-file-upload", specifier = "==1.3.0" },
|
{ name = "graphene-file-upload", specifier = "==1.3.0" },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue