Compare commits

...

7 Commits

Author SHA1 Message Date
0ac2ca73ec Release v25.10.13
All checks were successful
CI / Checks (push) Successful in 3m58s
2025-10-13 21:46:18 +02:00
7b67a2f758 BTHLABS-62: Display progress in extension popup
Co-authored-by: Tomek Wójcik <labs@tomekwojcik.pl>
Co-committed-by: Tomek Wójcik <labs@tomekwojcik.pl>
2025-10-13 18:48:00 +00:00
8b86145519 BTHLABS-61: Service layer refactoring
A journey to fix `ValidationError` in Pocket imports turned service
layer refactoring :D
2025-10-12 20:54:00 +02:00
ac7a8dd90e BTHLABS-0000: README.md fixes 2025-10-07 08:46:50 +02:00
6903b7f768 BTHLABS-0000: Nuked dotcom service
Moved to a separate repo
2025-10-07 08:45:10 +02:00
2e8b8d7330 BTHLABS-60: Appearance settings
Co-authored-by: Tomek Wójcik <labs@tomekwojcik.pl>
Co-committed-by: Tomek Wójcik <labs@tomekwojcik.pl>
2025-10-07 04:42:58 +00:00
b4d5375954 BTHLABS-0000: Docker and CI tweaks
Co-authored-by: Tomek Wójcik <labs@tomekwojcik.pl>
Co-committed-by: Tomek Wójcik <labs@tomekwojcik.pl>
2025-10-07 04:37:01 +00:00
92 changed files with 1441 additions and 509 deletions

View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -e
set +x
set -o pipefail
cat >"./docker-compose-ci-${COMPOSE_PROJECT}.yaml" <<EOF
services:
postgres:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/postgres:15.13-${COMPOSE_PROJECT}"
keycloak:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/keycloak:22.0.3-${COMPOSE_PROJECT}"
rabbitmq:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/rabbitmq:3.10.8-${COMPOSE_PROJECT}"
apple-ci:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/apple:ci-${COMPOSE_PROJECT}"
backend-ci:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:ci-${COMPOSE_PROJECT}"
extension-ci:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/extension:ci-${COMPOSE_PROJECT}"
packages-ci:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/packages:ci-${COMPOSE_PROJECT}"
EOF

View File

@ -17,8 +17,35 @@ jobs:
steps: steps:
- name: "Checkout the code" - name: "Checkout the code"
uses: "actions/checkout@v2" uses: "actions/checkout@v2"
- name: "Get run info"
id: "get-run-info"
run: |
set -x
echo "COMPOSE_PROJECT=${{ vars.COMPOSE_PROJECT_BASE }}-${GITHUB_RUN_NUMBER}" >> $GITHUB_OUTPUT
- name: "Get build options"
id: "get-build-options"
run: |
set -x
SHORT_SHA="${GITHUB_SHA::8}"
BUILD_ARCH="amd64"
BUILD_PLATFORM="linux/amd64"
if [ "${RUNNER_ARCH}" = "ARM64" ];then
BUILD_ARCH="arm64"
BUILD_PLATFORM="linux/arm64"
fi
echo "SHORT_SHA=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "BUILD_ARCH=$BUILD_ARCH" >> $GITHUB_OUTPUT
echo "BUILD_PLATFORM=$BUILD_PLATFORM" >> $GITHUB_OUTPUT
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
id: "setup-docker-buildx"
uses: "docker/setup-buildx-action@v3" uses: "docker/setup-buildx-action@v3"
with:
driver: "remote"
endpoint: "tcp://builder-01.bthlab:2375"
platforms: "linux/amd64"
append: |
- endpoint: "tcp://builder-mac-01.bthlab:2375"
platforms: "linux/arm64"
- name: "Build `postgres` image" - name: "Build `postgres` image"
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@ -26,7 +53,10 @@ jobs:
context: "services/" context: "services/"
push: false push: false
load: true load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/postgres:15.13-local" tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/postgres:15.13-${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
platforms: "${{ steps.get-build-options.outputs.BUILD_PLATFORM }}"
cache-from: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket"
cache-to: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket,mode=max"
- name: "Build `keycloak` image" - name: "Build `keycloak` image"
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@ -34,7 +64,10 @@ jobs:
context: "services/" context: "services/"
push: false push: false
load: true load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/keycloak:22.0.3-local" tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/keycloak:22.0.3-${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
platforms: "${{ steps.get-build-options.outputs.BUILD_PLATFORM }}"
cache-from: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket"
cache-to: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket,mode=max"
- name: "Build `rabbitmq` image" - name: "Build `rabbitmq` image"
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@ -42,7 +75,10 @@ jobs:
context: "services/" context: "services/"
push: false push: false
load: true load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/rabbitmq:3.10.8-local" tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/rabbitmq:3.10.8-${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
platforms: "${{ steps.get-build-options.outputs.BUILD_PLATFORM }}"
cache-from: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket"
cache-to: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket,mode=max"
- name: "Build `backend-ci` image" - name: "Build `backend-ci` image"
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@ -51,7 +87,10 @@ jobs:
target: "ci" target: "ci"
push: false push: false
load: true load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:ci-local" tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:ci-${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
platforms: "${{ steps.get-build-options.outputs.BUILD_PLATFORM }}"
cache-from: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket"
cache-to: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket,mode=max"
- name: "Build `packages-ci` image" - name: "Build `packages-ci` image"
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@ -60,7 +99,10 @@ jobs:
target: "ci" target: "ci"
push: false push: false
load: true load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/packages:ci-local" tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/packages:ci-${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
platforms: "${{ steps.get-build-options.outputs.BUILD_PLATFORM }}"
cache-from: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket"
cache-to: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket,mode=max"
- name: "Build `extension-ci` image" - name: "Build `extension-ci` image"
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@ -69,23 +111,91 @@ jobs:
target: "ci" target: "ci"
push: false push: false
load: true load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/extension:ci-local" tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/extension:ci-${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
platforms: "${{ steps.get-build-options.outputs.BUILD_PLATFORM }}"
cache-from: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket"
cache-to: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket,mode=max"
- name: "Build `apple-ci` image"
uses: docker/build-push-action@v6
with:
file: "services/apple/Dockerfile"
context: "services/"
target: "ci"
push: false
load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/apple:ci-${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
platforms: "${{ steps.get-build-options.outputs.BUILD_PLATFORM }}"
cache-from: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket"
cache-to: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket,mode=max"
- name: "Prepare the build"
id: "prepare"
env:
COMPOSE_PROJECT: "${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
run: |
set -x
./.gitea/tools/render-docker-compose-ci.sh
- name: "Run `backend` checks" - name: "Run `backend` checks"
if: "steps.prepare.conclusion == 'success'"
env:
COMPOSE_PROJECT: "${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
run: | run: |
set -x set -x
docker compose -f docker-compose.yaml -f docker-compose-ci.yaml run --rm backend-ci inv ci docker compose \
-p "${COMPOSE_PROJECT}" \
-f "docker-compose.yaml" \
-f "docker-compose-ci.yaml" \
-f "docker-compose-ci-${COMPOSE_PROJECT}.yaml" \
run --rm \
backend-ci inv ci
- name: "Run `packages` checks" - name: "Run `packages` checks"
if: always() if: "steps.prepare.conclusion == 'success'"
env:
COMPOSE_PROJECT: "${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
run: | run: |
set -x set -x
docker compose -f docker-compose.yaml -f docker-compose-ci.yaml run --rm packages-ci inv ci docker compose \
-p "${COMPOSE_PROJECT}" \
-f "docker-compose.yaml" \
-f "docker-compose-ci.yaml" \
-f "docker-compose-ci-${COMPOSE_PROJECT}.yaml" \
run --rm \
packages-ci inv ci
- name: "Run `extension` checks" - name: "Run `extension` checks"
if: always() if: "steps.prepare.conclusion == 'success'"
env:
COMPOSE_PROJECT: "${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
run: | run: |
set -x set -x
docker compose -f docker-compose.yaml -f docker-compose-ci.yaml run --rm extension-ci inv ci docker compose \
-p "${COMPOSE_PROJECT}" \
-f "docker-compose.yaml" \
-f "docker-compose-ci.yaml" \
-f "docker-compose-ci-${COMPOSE_PROJECT}.yaml" \
run --rm \
extension-ci inv ci
- name: "Run `apple` checks"
if: "steps.prepare.conclusion == 'success'"
env:
COMPOSE_PROJECT: "${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
run: |
set -x
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "docker-compose.yaml" \
-f "docker-compose-ci.yaml" \
-f "docker-compose-ci-${COMPOSE_PROJECT}.yaml" \
run --rm \
apple-ci inv ci
- name: "Clean up" - name: "Clean up"
if: always() if: always()
env:
COMPOSE_PROJECT: "${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
run: | run: |
set -x set -x
docker compose -f docker-compose.yaml -f docker-compose-ci.yaml down --volumes docker compose \
-p "${COMPOSE_PROJECT}" \
-f "docker-compose.yaml" \
-f "docker-compose-ci.yaml" \
-f "docker-compose-ci-${COMPOSE_PROJECT}.yaml" \
down --volumes --rmi all || true
rm -f "docker-compose-ci-${COMPOSE_PROJECT}.yaml" || true

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
.envrc* .envrc*
.ipythonhome/ .ipythonhome/
/docker-compose-ci-*.yaml

View File

@ -86,3 +86,5 @@ Licensed under terms of the MIT License
Pepper Hot Solid icon Pepper Hot Solid icon
Copyright (c) Icons8 Copyright (c) Icons8
Licensed under terms of the MIT License Licensed under terms of the MIT License
Spinner Loader CSS from https://css-loaders.com/

View File

@ -66,7 +66,7 @@ $ docker run --rm -it \
-e HOTPOCKET_BACKEND_INITIAL_ACCOUNT_USERNAME=hotpocket \ -e HOTPOCKET_BACKEND_INITIAL_ACCOUNT_USERNAME=hotpocket \
-e HOTPOCKET_BACKEND_INITIAL_ACCOUNT_PASSWORD=hotpocketm4st3r \ -e HOTPOCKET_BACKEND_INITIAL_ACCOUNT_PASSWORD=hotpocketm4st3r \
-p 8000:8000 \ -p 8000:8000 \
docker-hosted.nexus.bthlabs.pl/hotpocket/backend:aio-v25.10.4-01 docker-hosted.nexus.bthlabs.pl/hotpocket/backend:aio-v25.10.13-01
``` ```
The command above will set up and start the application. The SQLite file will The command above will set up and start the application. The SQLite file will
@ -76,8 +76,7 @@ credentials. The Web app will be reachable at `http://127.0.0.1:8000/`.
The admin will be reachable at `http://127.0.0.1:8000/admin/`. The admin will be reachable at `http://127.0.0.1:8000/admin/`.
The `DJANGO_SETTINGS_MODULE` environment variable defaults to The `DJANGO_SETTINGS_MODULE` environment variable defaults to
`hotpocket_backend.settings.deployment.webapp`. This should be set to `hotpocket_backend.settings.deployment.aio`.
`hotpocket_backend.settings.deployment.admin` in the Admin container.
**NOTE:** The command above specifies wildly insecure `SECRET_KEY` which is **NOTE:** The command above specifies wildly insecure `SECRET_KEY` which is
used among other things to secure the session cookie. Please *please* used among other things to secure the session cookie. Please *please*
@ -94,7 +93,8 @@ backend etc. The final deployment will require services for at least the Web
app, the Celery worker and Celery Beat. Admin is optional. app, the Celery worker and Celery Beat. Admin is optional.
The `DJANGO_SETTINGS_MODULE` environment variable defaults to The `DJANGO_SETTINGS_MODULE` environment variable defaults to
`hotpocket_backend.settings.deployment.aio`. `hotpocket_backend.settings.deployment.webapp`. This should be set to
`hotpocket_backend.settings.deployment.admin` in the Admin container.
The `deployment/fullstack/docker-compose.yaml` file can be used as a The `deployment/fullstack/docker-compose.yaml` file can be used as a
starting point for full-stack deployments. starting point for full-stack deployments.

View File

@ -1,6 +1,6 @@
services: services:
backend: backend:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:aio-v25.10.4-01" image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:aio-v25.10.13-01"
environment: environment:
HOTPOCKET_BACKEND_SECRET_KEY: "thisisntright" HOTPOCKET_BACKEND_SECRET_KEY: "thisisntright"
HOTPOCKET_BACKEND_INITIAL_ACCOUNT_USERNAME: "hotpocket" HOTPOCKET_BACKEND_INITIAL_ACCOUNT_USERNAME: "hotpocket"

View File

@ -8,7 +8,7 @@ x-backend-environment: &x-backend-environment
services: services:
webapp: webapp:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.4-01" image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.13-01"
environment: environment:
<<: *x-backend-environment <<: *x-backend-environment
HOTPOCKET_BACKEND_ALLOWED_HOSTS: "app.staging.hotpocket.bthlab.bthlabs.net" HOTPOCKET_BACKEND_ALLOWED_HOSTS: "app.staging.hotpocket.bthlab.bthlabs.net"
@ -21,7 +21,7 @@ services:
restart: "unless-stopped" restart: "unless-stopped"
admin: admin:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.4-01" image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.13-01"
environment: environment:
<<: *x-backend-environment <<: *x-backend-environment
HOTPOCKET_BACKEND_APP: "admin" HOTPOCKET_BACKEND_APP: "admin"
@ -35,7 +35,7 @@ services:
restart: "unless-stopped" restart: "unless-stopped"
celery-worker: celery-worker:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.4-01" image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.13-01"
command: command:
- "/srv/venv/bin/celery" - "/srv/venv/bin/celery"
- "-A" - "-A"
@ -57,7 +57,7 @@ services:
restart: "unless-stopped" restart: "unless-stopped"
celery-beat: celery-beat:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.4-01" image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.13-01"
command: command:
- "/srv/venv/bin/celery" - "/srv/venv/bin/celery"
- "-A" - "-A"

View File

@ -2,6 +2,7 @@
"group": { "group": {
"default": { "default": {
"targets": [ "targets": [
"apple-management",
"backend-management", "backend-management",
"caddy", "caddy",
"extension-management", "extension-management",
@ -13,6 +14,28 @@
} }
}, },
"target": { "target": {
"apple-management": {
"context": "services/",
"dockerfile": "apple/Dockerfile",
"tags": [
"docker-hosted.nexus.bthlabs.pl/hotpocket/apple:local"
],
"target": "development",
"output": [
"type=docker,load=true,push=false"
]
},
"apple-ci": {
"context": "services/",
"dockerfile": "apple/Dockerfile",
"tags": [
"docker-hosted.nexus.bthlabs.pl/hotpocket/apple:ci-local"
],
"target": "ci",
"output": [
"type=docker,load=true,push=false"
]
},
"backend-management": { "backend-management": {
"context": "services/", "context": "services/",
"dockerfile": "backend/Dockerfile", "dockerfile": "backend/Dockerfile",

View File

@ -1,16 +1,17 @@
services: services:
postgres: postgres:
ports: [] ports: !override []
keycloak: keycloak:
command: "echo 'NOOP'" command: "echo 'NOOP'"
ports: [] ports: !override []
restart: "no" restart: "no"
rabbitmq: rabbitmq:
ports: [] ports: !override []
include: include:
- path: "./services/backend/docker-compose-ci.yaml" - path: "./services/backend/docker-compose-ci.yaml"
- path: "./services/packages/docker-compose-ci.yaml" - path: "./services/packages/docker-compose-ci.yaml"
- path: "./services/extension/docker-compose-ci.yaml" - path: "./services/extension/docker-compose-ci.yaml"
- path: "./services/apple/docker-compose-ci.yaml"

View File

@ -6,6 +6,7 @@ include:
- path: "./services/backend/docker-compose.yaml" - path: "./services/backend/docker-compose.yaml"
- path: "./services/packages/docker-compose.yaml" - path: "./services/packages/docker-compose.yaml"
- path: "./services/extension/docker-compose.yaml" - path: "./services/extension/docker-compose.yaml"
- path: "./services/apple/docker-compose.yaml"
volumes: {} volumes: {}

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "hotpocket-workspace" name = "hotpocket-workspace"
version = "25.10.4" version = "25.10.13"
description = "HotPocket Workspace" description = "HotPocket Workspace"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"] authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
license = "Apache-2.0" license = "Apache-2.0"

View File

@ -1,5 +1,8 @@
.mypy_cache/
.pytest_cache/
_tmp/ _tmp/
apple/ apple/build/
apple/DerivedData/
backend/node_modules/ backend/node_modules/
backend/ops/metal/ backend/ops/metal/
backend/hotpocket_backend/playground.py backend/hotpocket_backend/playground.py

19
services/apple/Dockerfile Normal file
View File

@ -0,0 +1,19 @@
ARG APP_USER_UID=1000
ARG APP_USER_GID=1000
ARG IMAGE_ID=development.00000000
FROM docker-hosted.nexus.bthlabs.pl/hotpocket/base:build-node-20250819-01 AS development
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_ID
# COPY --chown=$APP_USER_UID:$APP_USER_GID apple/ops/bin/*.sh /srv/bin/
VOLUME ["/srv/node_modules", "/srv/venv"]
FROM development AS ci
COPY --chown=$APP_USER_UID:$APP_USER_GID apple/ /srv/app/
COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ /srv/packages/
COPY --chown=$APP_USER_UID:$APP_USER_GID tls/ /srv/tls/

View File

@ -713,7 +713,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = "iOS (Share Extension)/iOS (Share Extension).entitlements"; CODE_SIGN_ENTITLEMENTS = "iOS (Share Extension)/iOS (Share Extension).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401; CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iOS (Share Extension)/Info.plist"; INFOPLIST_FILE = "iOS (Share Extension)/Info.plist";
@ -726,7 +726,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 25.10.4; MARKETING_VERSION = 25.10.13;
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension;
PRODUCT_NAME = "Save to HotPocket"; PRODUCT_NAME = "Save to HotPocket";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -746,7 +746,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = "iOS (Share Extension)/iOS (Share Extension).entitlements"; CODE_SIGN_ENTITLEMENTS = "iOS (Share Extension)/iOS (Share Extension).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401; CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iOS (Share Extension)/Info.plist"; INFOPLIST_FILE = "iOS (Share Extension)/Info.plist";
@ -759,7 +759,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 25.10.4; MARKETING_VERSION = 25.10.13;
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension;
PRODUCT_NAME = "Save to HotPocket"; PRODUCT_NAME = "Save to HotPocket";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -779,7 +779,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401; CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iOS (Extension)/Info.plist"; INFOPLIST_FILE = "iOS (Extension)/Info.plist";
@ -792,7 +792,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 25.10.4; MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"-framework", "-framework",
SafariServices, SafariServices,
@ -814,7 +814,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401; CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iOS (Extension)/Info.plist"; INFOPLIST_FILE = "iOS (Extension)/Info.plist";
@ -827,7 +827,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 25.10.4; MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"-framework", "-framework",
SafariServices, SafariServices,
@ -853,7 +853,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "iOS (App)/HotPocket (iOS).entitlements"; CODE_SIGN_ENTITLEMENTS = "iOS (App)/HotPocket (iOS).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401; CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iOS (App)/Info.plist"; INFOPLIST_FILE = "iOS (App)/Info.plist";
@ -873,7 +873,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 25.10.4; MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"-framework", "-framework",
SafariServices, SafariServices,
@ -899,7 +899,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "iOS (App)/HotPocket (iOS).entitlements"; CODE_SIGN_ENTITLEMENTS = "iOS (App)/HotPocket (iOS).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401; CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iOS (App)/Info.plist"; INFOPLIST_FILE = "iOS (App)/Info.plist";
@ -919,7 +919,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 25.10.4; MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"-framework", "-framework",
SafariServices, SafariServices,
@ -945,7 +945,7 @@
CODE_SIGN_ENTITLEMENTS = "macOS (Extension)/HotPocket.entitlements"; CODE_SIGN_ENTITLEMENTS = "macOS (Extension)/HotPocket.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401; CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@ -960,7 +960,7 @@
"@executable_path/../../../../Frameworks", "@executable_path/../../../../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 25.10.4; MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"-framework", "-framework",
SafariServices, SafariServices,
@ -980,7 +980,7 @@
CODE_SIGN_ENTITLEMENTS = "macOS (Extension)/HotPocket.entitlements"; CODE_SIGN_ENTITLEMENTS = "macOS (Extension)/HotPocket.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401; CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@ -995,7 +995,7 @@
"@executable_path/../../../../Frameworks", "@executable_path/../../../../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 25.10.4; MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"-framework", "-framework",
SafariServices, SafariServices,
@ -1017,7 +1017,7 @@
CODE_SIGN_ENTITLEMENTS = "macOS (App)/HotPocket.entitlements"; CODE_SIGN_ENTITLEMENTS = "macOS (App)/HotPocket.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401; CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@ -1033,7 +1033,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 25.10.4; MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"-framework", "-framework",
SafariServices, SafariServices,
@ -1056,7 +1056,7 @@
CODE_SIGN_ENTITLEMENTS = "macOS (App)/HotPocket.entitlements"; CODE_SIGN_ENTITLEMENTS = "macOS (App)/HotPocket.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401; CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@ -1072,7 +1072,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 25.10.4; MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"-framework", "-framework",
SafariServices, SafariServices,
@ -1206,7 +1206,7 @@
CODE_SIGN_ENTITLEMENTS = "macOS (Share Extension)/macOS (Share Extension).entitlements"; CODE_SIGN_ENTITLEMENTS = "macOS (Share Extension)/macOS (Share Extension).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 2025100401; CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@ -1220,7 +1220,7 @@
"@executable_path/../../../../Frameworks", "@executable_path/../../../../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 25.10.4; MARKETING_VERSION = 25.10.13;
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension;
PRODUCT_NAME = "Save to HotPocket"; PRODUCT_NAME = "Save to HotPocket";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@ -1236,7 +1236,7 @@
CODE_SIGN_ENTITLEMENTS = "macOS (Share Extension)/macOS (Share Extension).entitlements"; CODE_SIGN_ENTITLEMENTS = "macOS (Share Extension)/macOS (Share Extension).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 2025100401; CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@ -1250,7 +1250,7 @@
"@executable_path/../../../../Frameworks", "@executable_path/../../../../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 25.10.4; MARKETING_VERSION = 25.10.13;
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension;
PRODUCT_NAME = "Save to HotPocket"; PRODUCT_NAME = "Save to HotPocket";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;

View File

@ -0,0 +1,23 @@
services:
apple-ci:
build:
context: ".."
dockerfile: "apple/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/apple:ci-local"
command: "echo 'NOOP'"
environment:
PYTHONBREAKPOINT: "ipdb.set_trace"
HOTPOCKET_PACKAGES_ENV: "${HOTPOCKET_EXTENSION_ENV:-docker}"
# REQUESTS_CA_BUNDLE: "/srv/tls/requests_ca_bundle.pem"
RUN_POETRY_INSTALL: "true"
RUN_YARN_INSTALL: "false"
SETUP_BACKEND: "true"
SETUP_FRONTEND: "false"
volumes:
- "apple_venv:/srv/venv"
- "apple_node_modules:/srv/node_modules"
- "../tls:/srv/tls"
restart: "no"
stdin_open: true
tty: true

View File

@ -0,0 +1,29 @@
services:
apple-management:
build:
context: ".."
dockerfile: "apple/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/apple:local"
command: "echo 'NOOP'"
environment: &apple-env
PYTHONBREAKPOINT: "ipdb.set_trace"
HOTPOCKET_EXTENSION_ENV: "${HOTPOCKET_EXTENSION_ENV:-docker}"
REQUESTS_CA_BUNDLE: "/srv/tls/requests_ca_bundle.pem"
RUN_POETRY_INSTALL: "true"
RUN_YARN_INSTALL: "false"
SETUP_BACKEND: "true"
SETUP_FRONTEND: "false"
volumes:
- "apple_venv:/srv/venv"
- "apple_node_modules:/srv/node_modules"
- ".:/srv/app"
- "../packages:/srv/packages"
- "../tls:/srv/tls"
restart: "no"
stdin_open: true
tty: true
volumes:
apple_venv:
apple_node_modules:

View File

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "hotpocket-apple" name = "hotpocket-apple"
version = "25.10.4" version = "25.10.13"
description = "HotPocket Apple Integrations" description = "HotPocket Apple Integrations"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"] authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
license = "Apache-2.0" license = "Apache-2.0"

View File

@ -8,12 +8,7 @@ ARG APP_USER_UID
ARG APP_USER_GID ARG APP_USER_GID
ARG IMAGE_ID ARG IMAGE_ID
USER root
COPY --chown=$APP_USER_UID:$APP_USER_GID backend/ops/bin/*.sh /srv/bin/ COPY --chown=$APP_USER_UID:$APP_USER_GID backend/ops/bin/*.sh /srv/bin/
RUN chown -R ${APP_USER_UID}:${APP_USER_GID} /srv
USER app
VOLUME ["/srv/node_modules", "/srv/venv"] VOLUME ["/srv/node_modules", "/srv/venv"]
@ -50,7 +45,6 @@ COPY --from=deployment-build /srv/packages /srv/packages
COPY --from=deployment-build /srv/venv /srv/venv COPY --from=deployment-build /srv/venv /srv/venv
COPY --chown=$APP_USER_UID:$APP_USER_GID backend/ops/bin/*.sh /srv/bin/ COPY --chown=$APP_USER_UID:$APP_USER_GID backend/ops/bin/*.sh /srv/bin/
COPY --chown=$APP_USER_UID:$APP_USER_GID backend/ops/deployment/gunicorn.conf.py backend/ops/deployment/gunicorn.logging.conf /srv/lib/ COPY --chown=$APP_USER_UID:$APP_USER_GID backend/ops/deployment/gunicorn.conf.py backend/ops/deployment/gunicorn.logging.conf /srv/lib/
RUN chown -R $APP_USER_UID:$APP_USER_GID /srv
USER root USER root
@ -109,5 +103,4 @@ COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ /srv/packages/
COPY --chown=$APP_USER_UID:$APP_USER_GID tls/ /srv/tls/ COPY --chown=$APP_USER_UID:$APP_USER_GID tls/ /srv/tls/
RUN ln -s /srv/app/ops/docker/settings /srv/app/hotpocket_backend/settings/docker && \ RUN ln -s /srv/app/ops/docker/settings /srv/app/hotpocket_backend/settings/docker && \
ln -s /srv/app/ops/docker/secrets /srv/app/hotpocket_backend/secrets/docker && \ ln -s /srv/app/ops/docker/secrets /srv/app/hotpocket_backend/secrets/docker
chown -R $APP_USER_UID:$APP_USER_GID /srv

View File

@ -1,4 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
version = '25.10.4' version = '25.10.13'

View File

@ -68,4 +68,10 @@ class Account(AbstractUser):
else: else:
result['auto_load_embeds'] = auto_load_embeds result['auto_load_embeds'] = auto_load_embeds
light_mode = result.get('light_mode', None)
if isinstance(light_mode, str) is True:
result['light_mode'] = (light_mode == 'True')
else:
result['light_mode'] = light_mode
return result return result

View File

@ -6,6 +6,7 @@ import hmac
import logging import logging
import uuid import uuid
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
import uuid6 import uuid6
@ -15,6 +16,10 @@ from hotpocket_soa.dto.accounts import (
AccessTokenMetaUpdateIn, AccessTokenMetaUpdateIn,
AccessTokensQuery, AccessTokensQuery,
) )
from hotpocket_soa.exceptions.backend import (
Invalid as InvalidError,
NotFound as NotFoundError,
)
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -23,7 +28,10 @@ class AccessTokensService:
class AccessTokensServiceError(Exception): class AccessTokensServiceError(Exception):
pass pass
class AccessTokenNotFound(AccessTokensServiceError): class Invalid(InvalidError, AccessTokensServiceError):
pass
class NotFound(NotFoundError, AccessTokensServiceError):
pass pass
def create(self, def create(self,
@ -32,20 +40,23 @@ class AccessTokensService:
origin: str, origin: str,
meta: dict, meta: dict,
) -> AccessToken: ) -> AccessToken:
pk = uuid6.uuid7() try:
key = hmac.new( pk = uuid6.uuid7()
settings.SECRET_KEY.encode('ascii'), key = hmac.new(
msg=pk.bytes, settings.SECRET_KEY.encode('ascii'),
digestmod=hashlib.sha256, msg=pk.bytes,
) digestmod=hashlib.sha256,
)
return AccessToken.objects.create( return AccessToken.objects.create(
pk=pk, pk=pk,
account_uuid=account_uuid, account_uuid=account_uuid,
key=key.hexdigest(), key=key.hexdigest(),
origin=origin, origin=origin,
meta=meta, meta=meta,
) )
except ValidationError as exception:
raise self.Invalid.from_django_validation_error(exception)
def get(self, *, pk: uuid.UUID) -> AccessToken: def get(self, *, pk: uuid.UUID) -> AccessToken:
try: try:
@ -53,7 +64,7 @@ class AccessTokensService:
return query_set.get(pk=pk) return query_set.get(pk=pk)
except AccessToken.DoesNotExist as exception: except AccessToken.DoesNotExist as exception:
raise self.AccessTokenNotFound( raise self.NotFound(
f'Access Token not found: pk=`{pk}`', f'Access Token not found: pk=`{pk}`',
) from exception ) from exception
@ -63,7 +74,7 @@ class AccessTokensService:
return query_set.get(key=key) return query_set.get(key=key)
except AccessToken.DoesNotExist as exception: except AccessToken.DoesNotExist as exception:
raise self.AccessTokenNotFound( raise self.NotFound(
f'Access Token not found: key=`{key}`', f'Access Token not found: key=`{key}`',
) from exception ) from exception
@ -98,7 +109,7 @@ class AccessTokensService:
pk: uuid.UUID, pk: uuid.UUID,
update: AccessTokenMetaUpdateIn, update: AccessTokenMetaUpdateIn,
) -> AccessToken: ) -> AccessToken:
access_token = AccessToken.active_objects.get(pk=pk) access_token = self.get(pk=pk)
next_meta = { next_meta = {
**(access_token.meta or {}), **(access_token.meta or {}),

View File

@ -5,6 +5,7 @@ import logging
import uuid import uuid
from hotpocket_backend.apps.accounts.models import Account from hotpocket_backend.apps.accounts.models import Account
from hotpocket_soa.exceptions.backend import NotFound as NotFoundError
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -13,7 +14,7 @@ class AccountsService:
class AccountsServiceError(Exception): class AccountsServiceError(Exception):
pass pass
class AccountNotFound(AccountsServiceError): class NotFound(NotFoundError, AccountsServiceError):
pass pass
def get(self, *, pk: uuid.UUID) -> Account: def get(self, *, pk: uuid.UUID) -> Account:
@ -22,6 +23,6 @@ class AccountsService:
return query_set.get(pk=pk) return query_set.get(pk=pk)
except Account.DoesNotExist as exception: except Account.DoesNotExist as exception:
raise self.AccountNotFound( raise self.NotFound(
f'Account not found: pk=`{pk}`', f'Account not found: pk=`{pk}`',
) from exception ) from exception

View File

@ -5,11 +5,17 @@ import datetime
import logging import logging
import uuid import uuid
from django.core.exceptions import ValidationError
from django.utils.timezone import now from django.utils.timezone import now
import uuid6 import uuid6
from hotpocket_backend.apps.accounts.models import AuthKey from hotpocket_backend.apps.accounts.models import AuthKey
from hotpocket_backend.apps.core.conf import settings from hotpocket_backend.apps.core.conf import settings
from hotpocket_soa.exceptions.backend import (
InternalError,
Invalid as InvalidError,
NotFound as NotFoundError,
)
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -18,22 +24,25 @@ class AuthKeysService:
class AuthKeysServiceError(Exception): class AuthKeysServiceError(Exception):
pass pass
class AuthKeyNotFound(AuthKeysServiceError): class Invalid(InvalidError, AuthKeysServiceError):
pass pass
class AuthKeyExpired(AuthKeysServiceError): class NotFound(NotFoundError, AuthKeysServiceError):
pass pass
class AuthKeyAccessDenied(AuthKeysServiceError): class Expired(InternalError, AuthKeysServiceError):
pass pass
def create(self, *, account_uuid: uuid.UUID) -> AuthKey: def create(self, *, account_uuid: uuid.UUID) -> AuthKey:
key = str(uuid6.uuid7()) try:
key = str(uuid6.uuid7())
return AuthKey.objects.create( return AuthKey.objects.create(
account_uuid=account_uuid, account_uuid=account_uuid,
key=key, key=key,
) )
except ValidationError as exception:
raise self.Invalid.from_django_validation_error(exception)
def get(self, *, pk: uuid.UUID) -> AuthKey: def get(self, *, pk: uuid.UUID) -> AuthKey:
try: try:
@ -41,7 +50,7 @@ class AuthKeysService:
return query_set.get(pk=pk) return query_set.get(pk=pk)
except AuthKey.DoesNotExist as exception: except AuthKey.DoesNotExist as exception:
raise self.AuthKeyNotFound( raise self.NotFound(
f'Auth Key not found: pk=`{pk}`', f'Auth Key not found: pk=`{pk}`',
) from exception ) from exception
@ -56,17 +65,17 @@ class AuthKeysService:
if ttl > 0: if ttl > 0:
if result.created_at < now() - datetime.timedelta(seconds=ttl): if result.created_at < now() - datetime.timedelta(seconds=ttl):
raise self.AuthKeyExpired( raise self.Expired(
f'Auth Key expired: pk=`{key}`', f'Auth Key expired: pk=`{key}`',
) )
if result.consumed_at is not None: if result.consumed_at is not None:
raise self.AuthKeyExpired( raise self.Expired(
f'Auth Key already consumed: pk=`{key}`', f'Auth Key already consumed: pk=`{key}`',
) )
return result return result
except AuthKey.DoesNotExist as exception: except AuthKey.DoesNotExist as exception:
raise self.AuthKeyNotFound( raise self.NotFound(
f'Auth Key not found: key=`{key}`', f'Auth Key not found: key=`{key}`',
) from exception ) from exception

View File

@ -1,16 +1,39 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
import functools
import typing import typing
from bthlabs_jsonrpc_core.exceptions import BaseJSONRPCError
from bthlabs_jsonrpc_django import ( from bthlabs_jsonrpc_django import (
DjangoExecutor, DjangoExecutor,
DjangoJSONRPCSerializer, DjangoJSONRPCSerializer,
JSONRPCView as BaseJSONRPCView, JSONRPCView as BaseJSONRPCView,
) )
from django.core.exceptions import ValidationError
import uuid6 import uuid6
from hotpocket_soa.exceptions.frontend import SOAError
class SOAJSONRPCError(BaseJSONRPCError):
ERROR_CODE = -32000
ERROR_MESSAGE = 'SOA Error'
def to_rpc(self) -> dict:
exception = typing.cast(SOAError, self.data)
code = (
exception.code
if exception.code is not None
else self.ERROR_CODE
)
return {
'code': code,
'message': exception.message or self.ERROR_MESSAGE,
'data': exception.data,
}
class JSONRPCSerializer(DjangoJSONRPCSerializer): class JSONRPCSerializer(DjangoJSONRPCSerializer):
STRING_COERCIBLE_TYPES: typing.Any = ( STRING_COERCIBLE_TYPES: typing.Any = (
@ -18,30 +41,6 @@ class JSONRPCSerializer(DjangoJSONRPCSerializer):
uuid6.UUID, uuid6.UUID,
) )
def serialize_value(self, value: typing.Any) -> typing.Any:
if isinstance(value, ValidationError):
result: typing.Any = None
if hasattr(value, 'error_dict') is True:
result = {}
for field, errors in value.error_dict.items():
result[field] = [
error.code
for error
in errors
]
elif hasattr(value, 'error_list') is True:
result = [
error.code
for error in value.error_list
]
else:
result = value.code
return self.serialize_value(result)
return super().serialize_value(value)
class Executor(DjangoExecutor): class Executor(DjangoExecutor):
serializer = JSONRPCSerializer serializer = JSONRPCSerializer
@ -49,3 +48,14 @@ class Executor(DjangoExecutor):
class JSONRPCView(BaseJSONRPCView): class JSONRPCView(BaseJSONRPCView):
executor = Executor executor = Executor
def wrap_soa_errors(func: typing.Callable) -> typing.Callable:
@functools.wraps(func)
def decorator(*args, **kwargs):
try:
return func(*args, **kwargs)
except SOAError as exception:
raise SOAJSONRPCError(exception)
return decorator

View File

@ -5,6 +5,7 @@ import datetime
import logging import logging
import uuid import uuid
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.timezone import now from django.utils.timezone import now
@ -15,6 +16,10 @@ from hotpocket_soa.dto.associations import (
AssociationsQuery, AssociationsQuery,
AssociationUpdateIn, AssociationUpdateIn,
) )
from hotpocket_soa.exceptions.backend import (
Invalid as InvalidError,
NotFound as NotFoundError,
)
from .saves import SavesService from .saves import SavesService
@ -25,7 +30,10 @@ class AssociationsService:
class AssociationsServiceError(Exception): class AssociationsServiceError(Exception):
pass pass
class AssociationNotFound(AssociationsServiceError): class Invalid(InvalidError, AssociationsServiceError):
pass
class NotFound(NotFoundError, AssociationsServiceError):
pass pass
@property @property
@ -46,30 +54,33 @@ class AssociationsService:
pk: uuid.UUID | None = None, pk: uuid.UUID | None = None,
created_at: datetime.datetime | None = None, created_at: datetime.datetime | None = None,
) -> Association: ) -> Association:
save = SavesService().get(pk=save_uuid) try:
save = SavesService().get(pk=save_uuid)
defaults = dict( defaults = dict(
account_uuid=account_uuid, account_uuid=account_uuid,
target=save, target=save,
) )
if pk is not None: if pk is not None:
defaults['id'] = pk defaults['id'] = pk
result, created = Association.objects.get_or_create( result, created = Association.objects.get_or_create(
account_uuid=account_uuid, account_uuid=account_uuid,
deleted_at__isnull=True, deleted_at__isnull=True,
target=save, target=save,
archived_at__isnull=True, archived_at__isnull=True,
defaults=defaults, defaults=defaults,
) )
if created is True: if created is True:
if created_at is not None: if created_at is not None:
result.created_at = created_at result.created_at = created_at
result.save() result.save()
return result return result
except ValidationError as exception:
raise self.Invalid.from_django_validation_error(exception)
def get(self, def get(self,
*, *,
@ -87,7 +98,7 @@ class AssociationsService:
return query_set.get(pk=pk) return query_set.get(pk=pk)
except Association.DoesNotExist as exception: except Association.DoesNotExist as exception:
raise self.AssociationNotFound( raise self.NotFound(
f'Association not found: pk=`{pk}`', f'Association not found: pk=`{pk}`',
) from exception ) from exception
@ -112,21 +123,24 @@ class AssociationsService:
pk: uuid.UUID, pk: uuid.UUID,
update: AssociationUpdateIn, update: AssociationUpdateIn,
) -> Association: ) -> Association:
association = self.get(pk=pk) try:
association.target_title = update.target_title association = self.get(pk=pk)
association.target_description = update.target_description association.target_title = update.target_title
association.target_description = update.target_description
next_target_meta = { next_target_meta = {
**(association.target_meta or {}), **(association.target_meta or {}),
} }
next_target_meta.pop('title', None) next_target_meta.pop('title', None)
next_target_meta.pop('description', None) next_target_meta.pop('description', None)
association.target_meta = next_target_meta association.target_meta = next_target_meta
association.save() association.save()
return association return association
except ValidationError as exception:
raise self.Invalid.from_django_validation_error(exception)
def archive(self, *, pk: uuid.UUID) -> bool: def archive(self, *, pk: uuid.UUID) -> bool:
association = self.get(pk=pk) association = self.get(pk=pk)

View File

@ -5,19 +5,27 @@ import hashlib
import typing import typing
import uuid import uuid
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from hotpocket_backend.apps.core.services import get_adapter from hotpocket_backend.apps.core.services import get_adapter
from hotpocket_backend.apps.saves.models import Save from hotpocket_backend.apps.saves.models import Save
from hotpocket_backend.apps.saves.types import PSaveAdapter from hotpocket_backend.apps.saves.types import PSaveAdapter
from hotpocket_soa.dto.saves import ImportedSaveIn, SaveIn, SavesQuery from hotpocket_soa.dto.saves import ImportedSaveIn, SaveIn, SavesQuery
from hotpocket_soa.exceptions.backend import (
Invalid as InvalidError,
NotFound as NotFoundError,
)
class SavesService: class SavesService:
class SavesServiceError(Exception): class SavesServiceError(Exception):
pass pass
class SaveNotFound(SavesServiceError): class Invalid(InvalidError, SavesServiceError):
pass
class NotFound(NotFoundError, SavesServiceError):
pass pass
@property @property
@ -36,35 +44,38 @@ class SavesService:
account_uuid: uuid.UUID, account_uuid: uuid.UUID,
save: SaveIn | ImportedSaveIn, save: SaveIn | ImportedSaveIn,
) -> Save: ) -> Save:
key = hashlib.sha256(save.url.encode('utf-8')).hexdigest() try:
key = hashlib.sha256(save.url.encode('utf-8')).hexdigest()
defaults = dict( defaults = dict(
account_uuid=account_uuid, account_uuid=account_uuid,
key=key, key=key,
url=save.url, url=save.url,
) )
save_object, created = Save.objects.get_or_create( save_object, created = Save.objects.get_or_create(
key=key, key=key,
deleted_at__isnull=True, deleted_at__isnull=True,
defaults=defaults, defaults=defaults,
) )
if created is True: if created is True:
save_object.is_netloc_banned = save.is_netloc_banned save_object.is_netloc_banned = save.is_netloc_banned
if isinstance(save, ImportedSaveIn) is True: if isinstance(save, ImportedSaveIn) is True:
save_object.title = save.title # type: ignore[union-attr] save_object.title = save.title # type: ignore[union-attr]
save_object.save() save_object.save()
return save_object return save_object
except ValidationError as exception:
raise self.Invalid.from_django_validation_error(exception)
def get(self, *, pk: uuid.UUID) -> Save: def get(self, *, pk: uuid.UUID) -> Save:
try: try:
return Save.active_objects.get(pk=pk) return Save.active_objects.get(pk=pk)
except Save.DoesNotExist as exception: except Save.DoesNotExist as exception:
raise self.SaveNotFound( raise self.NotFound(
f'Save not found: pk=`{pk}`', f'Save not found: pk=`{pk}`',
) from exception ) from exception

View File

@ -46,3 +46,33 @@ def version(request: HttpRequest) -> dict:
return { return {
'VERSION': backend_version, 'VERSION': backend_version,
} }
def appearance_settings(request: HttpRequest) -> dict:
theme = 'hotpocket'
result = {
'theme': theme,
'light_mode': False,
'theme_css': 'ui/css/bootstrap-hotpocket.min.css',
}
if request.user.is_anonymous is False:
theme = request.user.settings.get('theme', 'hotpocket')
result.update({
'theme': theme,
'light_mode': request.user.settings['light_mode'],
})
match theme:
case 'bootstrap':
result['theme_css'] = 'ui/css/bootstrap.min.css'
case 'cosmo':
result['theme_css'] = 'ui/css/cosmo.min.css'
case 'solar':
result['theme_css'] = 'ui/css/solar.min.css'
return {
'APPEARANCE_SETTINGS': result,
}

View File

@ -143,13 +143,18 @@ class SettingsForm(Form):
theme = forms.ChoiceField( theme = forms.ChoiceField(
label=_('Theme'), label=_('Theme'),
disabled=True, disabled=False,
required=False, required=False,
choices=[ choices=[
(None, _('Bootstrap')), (None, _('BTHLabs')),
('bootstrap', _('Bootstrap')),
('cosmo', _('Cosmo')), ('cosmo', _('Cosmo')),
('solar', _('Solar')),
], ],
show_hidden_initial=True, )
light_mode = forms.BooleanField(
label=_('Use light mode'),
required=False,
) )
auto_load_embeds = forms.ChoiceField( auto_load_embeds = forms.ChoiceField(
label=_('Auto load embedded content'), label=_('Auto load embedded content'),
@ -168,6 +173,7 @@ class SettingsForm(Form):
def get_layout_fields(self) -> list[str]: def get_layout_fields(self) -> list[str]:
return [ return [
'theme', 'theme',
'light_mode',
'auto_load_embeds', 'auto_load_embeds',
] ]

View File

@ -7,6 +7,7 @@ from bthlabs_jsonrpc_core import register_method
from django import db from django import db
from django.http import HttpRequest from django.http import HttpRequest
from hotpocket_backend.apps.core.rpc import wrap_soa_errors
from hotpocket_soa.services import ( from hotpocket_soa.services import (
AccessTokensService, AccessTokensService,
AccountsService, AccountsService,
@ -17,6 +18,7 @@ LOGGER = logging.getLogger(__name__)
@register_method('accounts.access_tokens.create', namespace='accounts') @register_method('accounts.access_tokens.create', namespace='accounts')
@wrap_soa_errors
def create(request: HttpRequest, def create(request: HttpRequest,
auth_key: str, auth_key: str,
meta: dict, meta: dict,
@ -27,7 +29,7 @@ def create(request: HttpRequest,
account_uuid=None, account_uuid=None,
key=auth_key, key=auth_key,
) )
except AuthKeysService.AuthKeyNotFound as exception: except AuthKeysService.NotFound as exception:
LOGGER.error( LOGGER.error(
'Unable to issue access token: %s', 'Unable to issue access token: %s',
exception, exception,
@ -37,7 +39,7 @@ def create(request: HttpRequest,
try: try:
account = AccountsService().get(pk=auth_key_object.account_uuid) account = AccountsService().get(pk=auth_key_object.account_uuid)
except AccountsService.AccountNotFound as exception: except AccountsService.NotFound as exception:
LOGGER.error( LOGGER.error(
'Unable to issue access token: %s', 'Unable to issue access token: %s',
exception, exception,

View File

@ -44,7 +44,7 @@ def check_access_token(request: HttpRequest,
access_token=access_token_object, access_token=access_token_object,
update=meta_update, update=meta_update,
) )
except AccessTokensService.AccessTokenNotFound as exception: except AccessTokensService.NotFound as exception:
LOGGER.error( LOGGER.error(
'Access Token not found: account_uuid=`%s` key=`%s`', 'Access Token not found: account_uuid=`%s` key=`%s`',
request.user.pk, request.user.pk,
@ -52,7 +52,7 @@ def check_access_token(request: HttpRequest,
exc_info=exception, exc_info=exception,
) )
result = False result = False
except AccessTokensService.AccessTokenAccessDenied as exception: except AccessTokensService.AccessDenied as exception:
LOGGER.error( LOGGER.error(
'Access Token access denied: account_uuid=`%s` key=`%s`', 'Access Token access denied: account_uuid=`%s` key=`%s`',
request.user.pk, request.user.pk,

View File

@ -4,11 +4,13 @@ from __future__ import annotations
from bthlabs_jsonrpc_core import register_method from bthlabs_jsonrpc_core import register_method
from django.http import HttpRequest from django.http import HttpRequest
from hotpocket_backend.apps.core.rpc import wrap_soa_errors
from hotpocket_backend.apps.ui.services.workflows import CreateSaveWorkflow from hotpocket_backend.apps.ui.services.workflows import CreateSaveWorkflow
from hotpocket_soa.dto.associations import AssociationOut from hotpocket_soa.dto.associations import AssociationOut
@register_method(method='saves.create') @register_method(method='saves.create')
@wrap_soa_errors
def create(request: HttpRequest, url: str) -> AssociationOut: def create(request: HttpRequest, url: str) -> AssociationOut:
association = CreateSaveWorkflow().run_rpc( association = CreateSaveWorkflow().run_rpc(
request=request, request=request,

View File

@ -27,7 +27,7 @@ class UIAccessTokensService:
account_uuid=account_uuid, account_uuid=account_uuid,
pk=pk, pk=pk,
) )
except AccessTokensService.AccessTokenNotFound as exception: except AccessTokensService.NotFound as exception:
LOGGER.error( LOGGER.error(
'Access Token not found: account_uuid=`%s` pk=`%s`', 'Access Token not found: account_uuid=`%s` pk=`%s`',
account_uuid, account_uuid,
@ -35,7 +35,7 @@ class UIAccessTokensService:
exc_info=exception, exc_info=exception,
) )
raise Http404() raise Http404()
except AccessTokensService.AccessTokenAccessDenied as exception: except AccessTokensService.AccessDenied as exception:
LOGGER.error( LOGGER.error(
'Access Token access denied: account_uuid=`%s` pk=`%s`', 'Access Token access denied: account_uuid=`%s` pk=`%s`',
account_uuid, account_uuid,

View File

@ -34,7 +34,7 @@ class UIAssociationsService:
with_target=True, with_target=True,
allow_archived=allow_archived, allow_archived=allow_archived,
) )
except AssociationsService.AssociationNotFound as exception: except AssociationsService.NotFound as exception:
LOGGER.error( LOGGER.error(
'Association not found: account_uuid=`%s` pk=`%s`', 'Association not found: account_uuid=`%s` pk=`%s`',
account_uuid, account_uuid,
@ -42,7 +42,7 @@ class UIAssociationsService:
exc_info=exception, exc_info=exception,
) )
raise Http404() raise Http404()
except AssociationsService.AssociationAccessDenied as exception: except AssociationsService.AccessDenied as exception:
LOGGER.error( LOGGER.error(
'Association access denied: account_uuid=`%s` pk=`%s`', 'Association access denied: account_uuid=`%s` pk=`%s`',
account_uuid, account_uuid,

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import csv import csv
import datetime import datetime
import logging
import os import os
import uuid import uuid
@ -13,16 +14,28 @@ from django.utils.timezone import get_current_timezone
from hotpocket_backend.apps.ui.services.workflows import ImportSaveWorkflow from hotpocket_backend.apps.ui.services.workflows import ImportSaveWorkflow
from hotpocket_backend.apps.ui.tasks import import_from_pocket from hotpocket_backend.apps.ui.tasks import import_from_pocket
from hotpocket_common.uuid import uuid7_from_timestamp from hotpocket_common.uuid import uuid7_from_timestamp
from hotpocket_soa.services import SavesService
LOGGER = logging.getLogger(__name__)
class UIImportsService: class UIImportsService:
def import_from_pocket(self, def import_from_pocket(self,
*, *,
job: str,
account_uuid: uuid.UUID, account_uuid: uuid.UUID,
csv_path: str, csv_path: str,
) -> list[tuple[uuid.UUID, uuid.UUID]]: ) -> list[tuple[uuid.UUID, uuid.UUID]]:
result = [] LOGGER.info(
'Starting import job: job=`%s` account_uuid=`%s`',
job,
account_uuid,
extra={
'job': job,
},
)
result = []
with db.transaction.atomic(): with db.transaction.atomic():
try: try:
with open(csv_path, 'r', encoding='utf-8') as csv_file: with open(csv_path, 'r', encoding='utf-8') as csv_file:
@ -34,22 +47,35 @@ class UIImportsService:
current_timezone = get_current_timezone() current_timezone = get_current_timezone()
is_header = False is_header = False
for row in csv_reader: for row_number, row in enumerate(csv_reader, start=1):
if is_header is False: if is_header is False:
is_header = True is_header = True
continue continue
timestamp = int(row['time_added']) timestamp = int(row['time_added'])
save, association = ImportSaveWorkflow().run( try:
account_uuid=account_uuid, save, association = ImportSaveWorkflow().run(
url=row['url'], account_uuid=account_uuid,
title=row['title'], url=row['url'],
pk=uuid7_from_timestamp(timestamp), title=row['title'],
created_at=datetime.datetime.fromtimestamp( pk=uuid7_from_timestamp(timestamp),
timestamp, tz=current_timezone, created_at=datetime.datetime.fromtimestamp(
), timestamp, tz=current_timezone,
) ),
)
except SavesService.Invalid as exception:
LOGGER.error(
'Import error: row_number=`%d` url=`%s` exception=`%s`',
row_number,
row['url'],
exception,
exc_info=exception,
extra={
'job': job,
},
)
continue
result.append((save.pk, association.pk)) result.append((save.pk, association.pk))
finally: finally:
@ -64,6 +90,7 @@ class UIImportsService:
) -> AsyncResult: ) -> AsyncResult:
return import_from_pocket.apply_async( return import_from_pocket.apply_async(
kwargs={ kwargs={
'job': str(uuid.uuid4()),
'account_uuid': account_uuid, 'account_uuid': account_uuid,
'csv_path': csv_path, 'csv_path': csv_path,
}, },

View File

@ -19,7 +19,7 @@ class UISavesService:
def get_or_404(self, *, pk: uuid.UUID) -> SaveOut: def get_or_404(self, *, pk: uuid.UUID) -> SaveOut:
try: try:
return SavesService().get(pk=pk) return SavesService().get(pk=pk)
except SavesService.SaveNotFound as exception: except SavesService.NotFound as exception:
LOGGER.error( LOGGER.error(
'Save not found: pk=`%s`', pk, exc_info=exception, 'Save not found: pk=`%s`', pk, exc_info=exception,
) )

View File

@ -1,9 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
from bthlabs_jsonrpc_core import JSONRPCInternalError
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError
import django.db import django.db
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
@ -14,7 +12,6 @@ from hotpocket_backend.apps.accounts.types import PAccount
from hotpocket_soa.dto.associations import AssociationOut from hotpocket_soa.dto.associations import AssociationOut
from hotpocket_soa.dto.celery import AsyncResultOut from hotpocket_soa.dto.celery import AsyncResultOut
from hotpocket_soa.dto.saves import SaveIn, SaveOut from hotpocket_soa.dto.saves import SaveIn, SaveOut
from hotpocket_soa.services import SavesService
from .base import SaveWorkflow from .base import SaveWorkflow
@ -73,14 +70,8 @@ class CreateSaveWorkflow(SaveWorkflow):
account: PAccount, account: PAccount,
url: str, url: str,
) -> AssociationOut: ) -> AssociationOut:
try: save, association, processing_result = self.create_associate_and_process(
save, association, processing_result = self.create_associate_and_process( account, url,
account, url, )
)
return association return association
except SavesService.SavesServiceError as exception:
if isinstance(exception.__cause__, ValidationError) is True:
raise JSONRPCInternalError(data=exception.__cause__)
raise

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -11,6 +11,7 @@ LOGGER = logging.getLogger(__name__)
@shared_task @shared_task
def import_from_pocket(*, def import_from_pocket(*,
job: str,
account_uuid: uuid.UUID, account_uuid: uuid.UUID,
csv_path: str, csv_path: str,
) -> list[tuple[uuid.UUID, uuid.UUID]]: ) -> list[tuple[uuid.UUID, uuid.UUID]]:
@ -18,6 +19,7 @@ def import_from_pocket(*,
try: try:
return UIImportsService().import_from_pocket( return UIImportsService().import_from_pocket(
job=job,
account_uuid=account_uuid, account_uuid=account_uuid,
csv_path=csv_path, csv_path=csv_path,
) )

View File

@ -10,14 +10,18 @@
|_| |_|
production production
--> -->
<html lang="en" data-bs-theme="dark"> <html lang="en" data-bs-theme="{% if APPEARANCE_SETTINGS.light_mode %}light{% else %}dark{% endif %}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no"> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no">
<meta name="generator" content="pl.bthlabs.HotPocket.backend@{{ IMAGE_ID }}"> <meta name="generator" content="pl.bthlabs.HotPocket.backend@{{ IMAGE_ID }}">
<meta name="theme-color" content="#2b3035"/> {% if APPEARANCE_SETTINGS.light_mode %}
<meta name="theme-color" content="#f8f9fa" />
{% else %}
<meta name="theme-color" content="#2b3035" />
{% endif %}
<title>{% block title %}{% translate 'Not Found' %}{% endblock %} | {{ SITE_TITLE }}</title> <title>{% block title %}{% translate 'Not Found' %}{% endblock %} | {{ SITE_TITLE }}</title>
<link href="{% static 'ui/css/bootstrap.min.css' %}" rel="stylesheet"> <link href="{% static APPEARANCE_SETTINGS.theme_css %}" rel="stylesheet">
<link href="{% static 'ui/css/bootstrap-icons.min.css' %}" rel="stylesheet"> <link href="{% static 'ui/css/bootstrap-icons.min.css' %}" rel="stylesheet">
<link href="{% static 'ui/css/hotpocket-backend.css' %}" rel="stylesheet"> <link href="{% static 'ui/css/hotpocket-backend.css' %}" rel="stylesheet">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">

View File

@ -9,6 +9,10 @@ from hotpocket_backend.apps.core.conf import settings
def manifest_json(request: HttpRequest) -> JsonResponse: def manifest_json(request: HttpRequest) -> JsonResponse:
light_theme = False
if request.user.is_anonymous is False:
light_theme = request.user.settings.get('light_theme', None) or False
result = { result = {
'name': settings.SITE_TITLE, 'name': settings.SITE_TITLE,
'short_name': settings.SITE_SHORT_TITLE, 'short_name': settings.SITE_SHORT_TITLE,
@ -16,8 +20,17 @@ def manifest_json(request: HttpRequest) -> JsonResponse:
reverse('ui.associations.browse'), reverse('ui.associations.browse'),
), ),
'display': 'standalone', 'display': 'standalone',
'background_color': '#212529', 'background_color': (
'theme_color': '#2b3035', '#212529'
if light_theme is False
else '#ffffff'
),
'theme_color': (
'#2b3035'
if light_theme is False
else
'#f8f9fa'
),
'icons': [ 'icons': [
{ {
'src': request.build_absolute_uri( 'src': request.build_absolute_uri(

View File

@ -67,6 +67,7 @@ TEMPLATES = [
'hotpocket_backend.apps.ui.context_processors.htmx', 'hotpocket_backend.apps.ui.context_processors.htmx',
'hotpocket_backend.apps.ui.context_processors.debug', 'hotpocket_backend.apps.ui.context_processors.debug',
'hotpocket_backend.apps.ui.context_processors.version', 'hotpocket_backend.apps.ui.context_processors.version',
'hotpocket_backend.apps.ui.context_processors.appearance_settings',
], ],
}, },
}, },

View File

@ -13,7 +13,7 @@ cat <<EOF
|_| |_|
production production
HotPocket v25.10.4 [${HOTPOCKET_BACKEND_IMAGE_ID}] (https://hotpocket.app/) HotPocket v25.10.13 [${HOTPOCKET_BACKEND_IMAGE_ID}] (https://hotpocket.app/)
Copyright 2025-present by BTHLabs. All rights reserved. (https://bthlabs.pl/) Copyright 2025-present by BTHLabs. All rights reserved. (https://bthlabs.pl/)
Licensed under Apache-2.0 Licensed under Apache-2.0
EOF EOF

View File

@ -1,6 +1,6 @@
{ {
"name": "hotpocket-backend", "name": "hotpocket-backend",
"version": "25.10.4", "version": "25.10.13",
"description": "HotPocket Backend", "description": "HotPocket Backend",
"main": "hotpocket_backend/apps/frontend/src/index.js", "main": "hotpocket_backend/apps/frontend/src/index.js",
"repository": "https://git.bthlabs.pl/tomekwojcik/hotpocket", "repository": "https://git.bthlabs.pl/tomekwojcik/hotpocket",

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "hotpocket-backend" name = "hotpocket-backend"
version = "25.10.4" version = "25.10.13"
description = "HotPocket Backend" description = "HotPocket Backend"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"] authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
license = "Apache-2.0" license = "Apache-2.0"

View File

@ -76,6 +76,18 @@ def starred_association_out(starred_association):
) )
@pytest.fixture
def deleted_save_association(association_factory, deleted_save):
return association_factory(target=deleted_save)
@pytest.fixture
def deleted_save_association_out(deleted_save_association):
return AssociationWithTargetOut.model_validate(
deleted_save_association, from_attributes=True,
)
@pytest.fixture @pytest.fixture
def other_account_association(association_factory, other_account): def other_account_association(association_factory, other_account):
return association_factory(account=other_account) return association_factory(account=other_account)
@ -124,6 +136,18 @@ def other_account_starred_association_out(other_account_starred_association):
) )
@pytest.fixture
def other_account_deleted_save_association(association_factory, other_account, deleted_save):
return association_factory(account=other_account, target=deleted_save)
@pytest.fixture
def other_account_deleted_save_association_out(other_account_deleted_save_association):
return AssociationWithTargetOut.model_validate(
other_account_deleted_save_association, from_attributes=True,
)
@pytest.fixture @pytest.fixture
def browsable_associations(association, def browsable_associations(association,
deleted_association, deleted_association,

View File

@ -64,11 +64,25 @@ def pocket_import_banned_netloc_save_spec():
}) })
@pytest.fixture
def pocket_import_invalid_url_spec():
return PocketImportSaveSpec.model_validate({
'title': "This isn't right",
'url': 'thisisntright',
'time_added': datetime.datetime(
2021, 1, 18, 8, 0, 0, 0, tzinfo=datetime.UTC,
),
'tags': '',
'status': 'unread',
})
@pytest.fixture @pytest.fixture
def pocket_csv_content(pocket_import_created_save_spec, def pocket_csv_content(pocket_import_created_save_spec,
pocket_import_reused_save_spec, pocket_import_reused_save_spec,
pocket_import_other_account_save_spec, pocket_import_other_account_save_spec,
pocket_import_banned_netloc_save_spec, pocket_import_banned_netloc_save_spec,
pocket_import_invalid_url_spec,
): ):
with io.StringIO() as csv_f: with io.StringIO() as csv_f:
field_names = [ field_names = [
@ -82,6 +96,7 @@ def pocket_csv_content(pocket_import_created_save_spec,
pocket_import_reused_save_spec.dict(), pocket_import_reused_save_spec.dict(),
pocket_import_other_account_save_spec.dict(), pocket_import_other_account_save_spec.dict(),
pocket_import_banned_netloc_save_spec.dict(), pocket_import_banned_netloc_save_spec.dict(),
pocket_import_invalid_url_spec.dict(),
]) ])
csv_f.seek(0) csv_f.seek(0)

View File

@ -29,6 +29,20 @@ class AssociationsTestingService:
if reference is not None: if reference is not None:
assert association.updated_at > reference.updated_at assert association.updated_at > reference.updated_at
def assert_deleted(self, *, pk: uuid.UUID, reference: typing.Any = None):
association = Association.objects.get(pk=pk)
assert association.deleted_at is not None
if reference is not None:
assert association.updated_at > reference.updated_at
def assert_not_deleted(self, *, pk: uuid.UUID, reference: typing.Any = None):
association = Association.objects.get(pk=pk)
assert association.deleted_at is None
if reference is not None:
assert association.updated_at == reference.updated_at
def assert_starred(self, *, pk: uuid.UUID, reference: typing.Any = None): def assert_starred(self, *, pk: uuid.UUID, reference: typing.Any = None):
association = Association.objects.get(pk=pk) association = Association.objects.get(pk=pk)
assert association.starred_at is not None assert association.starred_at is not None

View File

@ -45,10 +45,12 @@ def test_ok(account,
other_account_save_out, other_account_save_out,
pocket_import_other_account_save_spec: PocketImportSaveSpec, pocket_import_other_account_save_spec: PocketImportSaveSpec,
pocket_import_banned_netloc_save_spec: PocketImportSaveSpec, pocket_import_banned_netloc_save_spec: PocketImportSaveSpec,
pocket_import_invalid_url_spec: PocketImportSaveSpec,
mock_saves_process_save_task_apply_async: mock.Mock, mock_saves_process_save_task_apply_async: mock.Mock,
): ):
# When # When
result = tasks_module.import_from_pocket( result = tasks_module.import_from_pocket(
job='test',
account_uuid=account.pk, account_uuid=account.pk,
csv_path=str(pocket_csv_file_path), csv_path=str(pocket_csv_file_path),
) )

View File

@ -16,6 +16,7 @@ from hotpocket_backend_testing.services.accounts import AccountsTestingService
def payload(): def payload():
return { return {
'theme': 'cosmo', 'theme': 'cosmo',
'light_mode': True,
'auto_load_embeds': 'True', 'auto_load_embeds': 'True',
} }
@ -41,7 +42,8 @@ def test_ok(authenticated_client: Client,
AccountsTestingService().assert_settings_edited( AccountsTestingService().assert_settings_edited(
pk=account.pk, pk=account.pk,
update={ update={
'theme': None, # TODO: Themes! 'theme': 'cosmo',
'light_mode': True,
'auto_load_embeds': True, 'auto_load_embeds': True,
}, },
reference=account, reference=account,

View File

@ -100,6 +100,54 @@ def test_invalid_all_empty(authenticated_client: Client,
assert 'canhazconfirm' in result.context['form'].errors assert 'canhazconfirm' in result.context['form'].errors
@pytest.mark.django_db
def test_archived(authenticated_client: Client,
archived_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.archive', args=(archived_association_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_deleted(authenticated_client: Client,
deleted_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.archive', args=(deleted_association_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_not_found(authenticated_client: Client,
null_uuid,
):
# When
result = authenticated_client.post(
reverse('ui.associations.archive', args=(null_uuid,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db @pytest.mark.django_db
def test_other_account_association(authenticated_client: Client, def test_other_account_association(authenticated_client: Client,
other_account_association_out, other_account_association_out,

View File

@ -9,7 +9,7 @@ from django.urls import reverse
import pytest import pytest
from pytest_django import asserts from pytest_django import asserts
from hotpocket_backend.apps.saves.models import Association from hotpocket_backend_testing.services.saves import AssociationsTestingService
from hotpocket_common.constants import AssociationsSearchMode from hotpocket_common.constants import AssociationsSearchMode
@ -35,9 +35,10 @@ def test_ok(authenticated_client: Client,
fetch_redirect_response=False, fetch_redirect_response=False,
) )
association_object = Association.objects.get(pk=association_out.pk) AssociationsTestingService().assert_deleted(
assert association_object.updated_at > association_out.updated_at pk=association_out.pk,
assert association_object.deleted_at is not None reference=association_out,
)
@pytest.mark.django_db @pytest.mark.django_db
@ -65,6 +66,34 @@ def test_ok_htmx(authenticated_client: Client,
assert result.json() == expected_payload assert result.json() == expected_payload
@pytest.mark.django_db
def test_ok_archived(authenticated_client: Client,
archived_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.delete', args=(archived_association_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
asserts.assertRedirects(
result,
reverse(
'ui.associations.browse',
query=[('mode', AssociationsSearchMode.ARCHIVED.value)],
),
fetch_redirect_response=False,
)
AssociationsTestingService().assert_deleted(
pk=archived_association_out.pk,
reference=archived_association_out,
)
@pytest.mark.django_db @pytest.mark.django_db
def test_invalid_all_missing(authenticated_client: Client, def test_invalid_all_missing(authenticated_client: Client,
association_out, association_out,
@ -78,13 +107,13 @@ def test_invalid_all_missing(authenticated_client: Client,
# Then # Then
assert result.status_code == http.HTTPStatus.OK assert result.status_code == http.HTTPStatus.OK
association_object = Association.objects.get(pk=association_out.pk)
assert association_object.updated_at == association_out.updated_at
assert association_object.deleted_at is None
assert 'canhazconfirm' in result.context['form'].errors assert 'canhazconfirm' in result.context['form'].errors
AssociationsTestingService().assert_not_deleted(
pk=association_out.pk,
reference=association_out,
)
@pytest.mark.django_db @pytest.mark.django_db
def test_invalid_all_empty(authenticated_client: Client, def test_invalid_all_empty(authenticated_client: Client,
@ -100,13 +129,45 @@ def test_invalid_all_empty(authenticated_client: Client,
# Then # Then
assert result.status_code == http.HTTPStatus.OK assert result.status_code == http.HTTPStatus.OK
association_object = Association.objects.get(pk=association_out.pk)
assert association_object.updated_at == association_out.updated_at
assert association_object.deleted_at is None
assert 'canhazconfirm' in result.context['form'].errors assert 'canhazconfirm' in result.context['form'].errors
AssociationsTestingService().assert_not_deleted(
pk=association_out.pk,
reference=association_out,
)
@pytest.mark.django_db
def test_deleted(authenticated_client: Client,
deleted_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.delete', args=(deleted_association_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_not_found(authenticated_client: Client,
null_uuid,
):
# When
result = authenticated_client.post(
reverse('ui.associations.delete', args=(null_uuid,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db @pytest.mark.django_db
def test_other_account_association(authenticated_client: Client, def test_other_account_association(authenticated_client: Client,

View File

@ -47,10 +47,10 @@ def test_ok(authenticated_client: Client,
@pytest.mark.django_db @pytest.mark.django_db
def test_invalid_all_empty(authenticated_client: Client, def test_all_empty(authenticated_client: Client,
association_out, association_out,
payload, payload,
): ):
# Given # Given
effective_payload = { effective_payload = {
key: '' key: ''
@ -79,9 +79,9 @@ def test_invalid_all_empty(authenticated_client: Client,
@pytest.mark.django_db @pytest.mark.django_db
def test_invalid_all_missing(authenticated_client: Client, def test_all_missing(authenticated_client: Client,
association_out, association_out,
): ):
# Given # Given
effective_payload = {} effective_payload = {}
@ -105,6 +105,45 @@ def test_invalid_all_missing(authenticated_client: Client,
) )
@pytest.mark.django_db
def test_archived(authenticated_client: Client,
archived_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.edit', args=(archived_association_out.pk,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_deleted(authenticated_client: Client,
deleted_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.edit', args=(deleted_association_out.pk,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_not_found(authenticated_client: Client,
null_uuid,
):
# When
result = authenticated_client.post(
reverse('ui.associations.edit', args=(null_uuid,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db @pytest.mark.django_db
def test_other_account_association(authenticated_client: Client, def test_other_account_association(authenticated_client: Client,
other_account_association_out, other_account_association_out,

View File

@ -128,6 +128,54 @@ def test_invalid_all_empty(authenticated_client: Client,
assert 'canhazconfirm' in result.context['form'].errors assert 'canhazconfirm' in result.context['form'].errors
@pytest.mark.django_db
def test_archived(authenticated_client: Client,
archived_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.refresh', args=(archived_association_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_deleted(authenticated_client: Client,
deleted_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.refresh', args=(deleted_association_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_not_found(authenticated_client: Client,
null_uuid,
):
# When
result = authenticated_client.post(
reverse('ui.associations.refresh', args=(null_uuid,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db @pytest.mark.django_db
def test_other_account_association(authenticated_client: Client, def test_other_account_association(authenticated_client: Client,
other_account_association_out, other_account_association_out,

View File

@ -54,6 +54,45 @@ def test_ok_htmx(authenticated_client: Client,
assert result.context['association'].target.pk == association_out.target.pk assert result.context['association'].target.pk == association_out.target.pk
@pytest.mark.django_db
def test_archived(authenticated_client: Client,
archived_association_out,
):
# When
result = authenticated_client.get(
reverse('ui.associations.star', args=(archived_association_out.pk,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_deleted(authenticated_client: Client,
deleted_association_out,
):
# When
result = authenticated_client.get(
reverse('ui.associations.star', args=(deleted_association_out.pk,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_not_found(authenticated_client: Client,
null_uuid,
):
# When
result = authenticated_client.get(
reverse('ui.associations.star', args=(null_uuid,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db @pytest.mark.django_db
def test_other_account_association(authenticated_client: Client, def test_other_account_association(authenticated_client: Client,
other_account_association_out, other_account_association_out,

View File

@ -54,6 +54,45 @@ def test_ok_htmx(authenticated_client: Client,
assert result.context['association'].target.pk == starred_association_out.target.pk assert result.context['association'].target.pk == starred_association_out.target.pk
@pytest.mark.django_db
def test_archived(authenticated_client: Client,
archived_association_out,
):
# When
result = authenticated_client.get(
reverse('ui.associations.unstar', args=(archived_association_out.pk,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_deleted(authenticated_client: Client,
deleted_association_out,
):
# When
result = authenticated_client.get(
reverse('ui.associations.unstar', args=(deleted_association_out.pk,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_not_found(authenticated_client: Client,
null_uuid,
):
# When
result = authenticated_client.get(
reverse('ui.associations.unstar', args=(null_uuid,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db @pytest.mark.django_db
def test_other_account_association(authenticated_client: Client, def test_other_account_association(authenticated_client: Client,
other_account_starred_association_out, other_account_starred_association_out,

View File

@ -66,6 +66,19 @@ def test_authenticated_deleted(authenticated_client: Client,
assert result.status_code == http.HTTPStatus.NOT_FOUND assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_authenticated_deleted_save(authenticated_client: Client,
deleted_save_association_out,
):
# When
result = authenticated_client.get(
reverse('ui.associations.view', args=(deleted_save_association_out.pk,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db @pytest.mark.django_db
def test_authenticated_not_found(authenticated_client: Client, def test_authenticated_not_found(authenticated_client: Client,
null_uuid, null_uuid,
@ -169,6 +182,23 @@ def test_authenticated_share_deleted(authenticated_client: Client,
assert result.status_code == http.HTTPStatus.NOT_FOUND assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_authenticated_share_deleted_save(authenticated_client: Client,
other_account_deleted_save_association_out,
):
# When
result = authenticated_client.get(
reverse(
'ui.associations.view',
args=(other_account_deleted_save_association_out.pk,),
query=[('share', 'true')],
),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db @pytest.mark.django_db
def test_authenticated_share_not_found(authenticated_client: Client, def test_authenticated_share_not_found(authenticated_client: Client,
null_uuid, null_uuid,
@ -240,6 +270,23 @@ def test_anonymous_share_deleted(client: Client,
assert result.status_code == http.HTTPStatus.NOT_FOUND assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_anonymous_share_deleted_save(client: Client,
deleted_save_association_out,
):
# When
result = client.get(
reverse(
'ui.associations.view',
args=(deleted_save_association_out.pk,),
query=[('share', 'true')],
),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db @pytest.mark.django_db
def test_anonymous_share_not_found(client: Client, def test_anonymous_share_not_found(client: Client,
null_uuid, null_uuid,

View File

@ -76,6 +76,7 @@ def test_ok(override_settings_upload_path,
mock_ui_import_from_pocket_task_apply_async.assert_called_once_with( mock_ui_import_from_pocket_task_apply_async.assert_called_once_with(
kwargs={ kwargs={
'job': mock.ANY,
'account_uuid': account.pk, 'account_uuid': account.pk,
'csv_path': str(uploaded_file_path), 'csv_path': str(uploaded_file_path),
}, },

View File

@ -77,10 +77,11 @@ def test_auth_key_not_found(null_uuid,
call_result = result.json() call_result = result.json()
assert 'error' in call_result assert 'error' in call_result
assert call_result['error']['data'].startswith( assert call_result['error']['code'] == -32001
assert call_result['error']['message'].startswith(
'Auth Key not found', 'Auth Key not found',
) )
assert call_auth_key in call_result['error']['data'] assert call_auth_key in call_result['error']['message']
@pytest.mark.django_db @pytest.mark.django_db
@ -108,10 +109,11 @@ def test_deleted_auth_key(deleted_auth_key_out,
call_result = result.json() call_result = result.json()
assert 'error' in call_result assert 'error' in call_result
assert call_result['error']['data'].startswith( assert call_result['error']['code'] == -32001
assert call_result['error']['message'].startswith(
'Auth Key not found', 'Auth Key not found',
) )
assert call_auth_key in call_result['error']['data'] assert call_auth_key in call_result['error']['message']
@pytest.mark.django_db @pytest.mark.django_db
@ -139,10 +141,11 @@ def test_expired_auth_key(expired_auth_key_out,
call_result = result.json() call_result = result.json()
assert 'error' in call_result assert 'error' in call_result
assert call_result['error']['data'].startswith( assert call_result['error']['code'] == -32000
assert call_result['error']['message'].startswith(
'Auth Key expired', 'Auth Key expired',
) )
assert call_auth_key in call_result['error']['data'] assert call_auth_key in call_result['error']['message']
@pytest.mark.django_db @pytest.mark.django_db
@ -170,10 +173,11 @@ def test_consumed_auth_key(consumed_auth_key,
call_result = result.json() call_result = result.json()
assert 'error' in call_result assert 'error' in call_result
assert call_result['error']['data'].startswith( assert call_result['error']['code'] == -32000
assert call_result['error']['message'].startswith(
'Auth Key already consumed', 'Auth Key already consumed',
) )
assert call_auth_key in call_result['error']['data'] assert call_auth_key in call_result['error']['message']
@pytest.mark.django_db @pytest.mark.django_db
@ -201,4 +205,5 @@ def test_inactive_account(inactive_account_auth_key,
call_result = result.json() call_result = result.json()
assert 'error' in call_result assert 'error' in call_result
assert str(inactive_account.pk) in call_result['error']['data'] assert call_result['error']['code'] == -32001
assert str(inactive_account.pk) in call_result['error']['message']

View File

@ -1,3 +0,0 @@
run:
echo: true
pty: true

View File

@ -1,14 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from invoke import task
@task
def publish(ctx):
ctx.run((
'rsync '
'-rv '
'webroot/ '
'snakeweb.net:/srv/sites/bilbo/hotpocket.app/dotcom/'
))

View File

@ -1,26 +0,0 @@
BTHLabsHotPocketBot
===================
BTHLabsHotPocketBot is the metadata bot software that BTHLabs HotPocket uses to
discover and collect metadata of links saved by its users.
BTHLabsHotPocketBot collects the following data from HTML documents:
* Page title extracted from ``meta`` and ``title`` tags.
* Description extract from ``meta`` tags.
Metadata collected by BTHLabsHotPocketBot is stored in HotPocket database and
displayed to users who saved the link.
How BTHLabsHotPocketBot accesses your site
------------------------------------------
BTHLabsHotPocketBot makes an HTTP request to the site when the link is saved
or a user requests metadata refresh through the UI. The response content is
then processed. The response content **is not** stored in any database.
Getting Support
----------------
If you have any questions about BTHLabsHotPocketBot, please contact us at
contact@bthlabs.pl and we will respond as soon as possible.

View File

@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hotpocket.app</title>
<meta name="robots" content="noindex, nofollow">
</head>
<body>
<h1>SOON</h1>
</body>
</html>

View File

@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hotpocket.app</title>
<meta name="robots" content="noindex, nofollow">
</head>
<body>
<h1>SOON</h1>
</body>
</html>

View File

@ -8,12 +8,7 @@ ARG APP_USER_UID
ARG APP_USER_GID ARG APP_USER_GID
ARG IMAGE_ID ARG IMAGE_ID
USER root
# COPY --chown=$APP_USER_UID:$APP_USER_GID extension/ops/bin/*.sh /srv/bin/ # COPY --chown=$APP_USER_UID:$APP_USER_GID extension/ops/bin/*.sh /srv/bin/
RUN chown -R ${APP_USER_UID}:${APP_USER_GID} /srv
USER app
VOLUME ["/srv/node_modules", "/srv/venv"] VOLUME ["/srv/node_modules", "/srv/venv"]
@ -22,5 +17,3 @@ FROM development AS ci
COPY --chown=$APP_USER_UID:$APP_USER_GID extension/ /srv/app/ COPY --chown=$APP_USER_UID:$APP_USER_GID extension/ /srv/app/
COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ /srv/packages/ COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ /srv/packages/
COPY --chown=$APP_USER_UID:$APP_USER_GID tls/ /srv/tls/ COPY --chown=$APP_USER_UID:$APP_USER_GID tls/ /srv/tls/
RUN chown -R $APP_USER_UID:$APP_USER_GID /srv

View File

View File

@ -1,6 +1,6 @@
{ {
"name": "hotpocket-extension", "name": "hotpocket-extension",
"version": "25.10.4", "version": "25.10.13",
"description": "HotPocket Extension", "description": "HotPocket Extension",
"main": "src/index.js", "main": "src/index.js",
"repository": "https://git.bthlabs.pl/tomekwojcik/hotpocket", "repository": "https://git.bthlabs.pl/tomekwojcik/hotpocket",

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "hotpocket-extension" name = "hotpocket-extension"
version = "25.10.4" version = "25.10.13"
description = "HotPocket Extension" description = "HotPocket Extension"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"] authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
license = "Apache-2.0" license = "Apache-2.0"

View File

@ -164,7 +164,7 @@ const doHandleAuthFlow = (authTab) => {
); );
const expectedSessionTabQuery = `?authSessionToken=${authSessionToken}`; const expectedSessionTabQuery = `?authSessionToken=${authSessionToken}`;
if (tabId !== currentAuthTabId && changedURL.includes(expectedSessionTabQuery)) { if (tabId !== currentAuthTabId && changedURL && changedURL.includes(expectedSessionTabQuery)) {
// When redirecting from the preauth page to the HotPocket instance, // When redirecting from the preauth page to the HotPocket instance,
// Safari "replaces" the auth tab with a new one. This nasty hack will // Safari "replaces" the auth tab with a new one. This nasty hack will
// allow the extension to keep track of it. // allow the extension to keep track of it.
@ -268,7 +268,7 @@ const doSetupRPC = async () => {
}; };
const doSendTabMessage = (tab, message) => { const doSendTabMessage = (tab, message) => {
HotPocketExtension.api.tabs.sendMessage(tab.id, message). return HotPocketExtension.api.tabs.sendMessage(tab.id, message).
then((result) => { then((result) => {
HotPocketExtension.LOGGER.debug( HotPocketExtension.LOGGER.debug(
'HotPocketExtension.background.doSendTabMessage(): message sent', 'HotPocketExtension.background.doSendTabMessage(): message sent',
@ -327,6 +327,10 @@ const onBrowserActionClicked = async (tab) => {
let result = false; let result = false;
let error = null; let error = null;
await doSendTabMessage(tab, {
type: 'HotPocket:Extension:browserActionClicked',
});
try { try {
let accessToken = await doSetupRPC(); let accessToken = await doSetupRPC();
@ -348,7 +352,7 @@ const onBrowserActionClicked = async (tab) => {
error: error, error: error,
}; };
doSendTabMessage(tab, message); return await doSendTabMessage(tab, message);
}; };
const onMessage = (message, sender, sendResponse) => { const onMessage = (message, sender, sendResponse) => {

View File

@ -1,6 +1,7 @@
import HotPocketExtension from '../common'; import HotPocketExtension from '../common';
import POPUP from './templates/popup.html'; import POPUP from './templates/popup.html';
import POPUP_CONTENT_SAVING from './templates/popup_content_saving.html';
import POPUP_CONTENT_SUCCESS from './templates/popup_content_success.html'; import POPUP_CONTENT_SUCCESS from './templates/popup_content_success.html';
import POPUP_CONTENT_ERROR from './templates/popup_content_error.html'; import POPUP_CONTENT_ERROR from './templates/popup_content_error.html';
@ -18,6 +19,19 @@ class Popup {
this.timeout = null; this.timeout = null;
} }
}; };
setContent = (content) => {
const shadow = this.container.shadowRoot;
const body = shadow.querySelector('.hotpocket-extension-popup-body');
body.innerHTML = content;
const i18nElements = shadow.querySelectorAll('[data-message]');
for (let i18nElement of i18nElements) {
i18nElement.innerHTML = HotPocketExtension.api.i18n.getMessage(
i18nElement.dataset.message,
);
}
};
close = () => { close = () => {
this.clearCloseTimeout(); this.clearCloseTimeout();
@ -36,15 +50,7 @@ class Popup {
const shadow = this.container.attachShadow({mode: 'open'}); const shadow = this.container.attachShadow({mode: 'open'});
shadow.innerHTML = POPUP; shadow.innerHTML = POPUP;
const body = shadow.querySelector('.hotpocket-extension-popup-body'); this.setContent(content);
body.innerHTML = content;
const i18nElements = shadow.querySelectorAll('[data-message]');
for (let i18nElement of i18nElements) {
i18nElement.innerHTML = HotPocketExtension.api.i18n.getMessage(
i18nElement.dataset.message,
);
}
const closeElements = shadow.querySelectorAll('.hotpocket-extension-popup-close'); const closeElements = shadow.querySelectorAll('.hotpocket-extension-popup-close');
for (const closeElement of closeElements) { for (const closeElement of closeElements) {
@ -53,6 +59,9 @@ class Popup {
document.body.appendChild(this.container); document.body.appendChild(this.container);
}; };
update = (content) => {
this.setContent(content);
};
onCloseClick = (event) => { onCloseClick = (event) => {
this.close(); this.close();
}; };
@ -60,15 +69,27 @@ class Popup {
let currentPopup = null; let currentPopup = null;
const doHandleSaveMessage = (message) => { const doHandleBrowserActionClickedMessage = (message) => {
if (currentPopup !== null) { if (currentPopup !== null) {
currentPopup.close(); currentPopup.close();
} }
currentPopup = new Popup(); currentPopup = new Popup();
currentPopup.show( currentPopup.show(POPUP_CONTENT_SAVING);
(message.result === true) ? POPUP_CONTENT_SUCCESS : POPUP_CONTENT_ERROR, };
);
const doHandleSaveMessage = (message) => {
let content = POPUP_CONTENT_ERROR;
if (message.result === true) {
content = POPUP_CONTENT_SUCCESS;
}
if (currentPopup === null) {
currentPopup = new Popup();
currentPopup.show(content);
} else {
currentPopup.update(content);
}
}; };
const doSendMessage = (message) => { const doSendMessage = (message) => {
@ -93,7 +114,9 @@ export default ({...configuration}) => {
let response = {ok: true}; let response = {ok: true};
try { try {
if (message.type === 'HotPocket:Extension:save') { if (message.type === 'HotPocket:Extension:browserActionClicked') {
doHandleBrowserActionClickedMessage(message);
} else if (message.type === 'HotPocket:Extension:save') {
doHandleSaveMessage(message); doHandleSaveMessage(message);
} }
} catch (exception) { } catch (exception) {

View File

@ -13,7 +13,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no"> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no">
<meta name="generator" content="pl.bthlabs.HotPocket.Extension@v25.10.4"> <meta name="generator" content="pl.bthlabs.HotPocket.Extension@v25.10.13">
<meta name="theme-color" content="#2b3035"/> <meta name="theme-color" content="#2b3035"/>
<title>HotPocket by BTHLabs</title> <title>HotPocket by BTHLabs</title>
<link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,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"> <link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,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">
@ -79,7 +79,7 @@ body, html {
</div> </div>
<p class="mb-0 mt-2 text-center text-muted ui-uname"> <p class="mb-0 mt-2 text-center text-muted ui-uname">
<span> <span>
<a href="https://hotpocket.app/" target="_blank" rel="noopener noreferer">HotPocket by BTHLabs</a> v25.10.4 <a href="https://hotpocket.app/" target="_blank" rel="noopener noreferer">HotPocket by BTHLabs</a> v25.10.13
</span> </span>
<br> <br>
<span>Copyright &copy; 2025-present by BTHLabs. All rights reserved.</span> <span>Copyright &copy; 2025-present by BTHLabs. All rights reserved.</span>

View File

@ -58,6 +58,9 @@
.hotpocket-extension-popup .hotpocket-extension-popup-body > * { .hotpocket-extension-popup .hotpocket-extension-popup-body > * {
margin: 0px; margin: 0px;
} }
.hotpocket-extension-popup .hotpocket-extension-popup-body > .hotpocket-extension-popup-loader {
margin: 0px auto;
}
.hotpocket-extension-popup .hotpocket-extension-popup-body strong { .hotpocket-extension-popup .hotpocket-extension-popup-body strong {
font-weight: 600; font-weight: 600;
} }
@ -67,6 +70,33 @@
.hotpocket-extension-popup .hotpocket-extension-popup-message-error { .hotpocket-extension-popup .hotpocket-extension-popup-message-error {
color: #EE6476; color: #EE6476;
} }
.hotpocket-extension-popup-loader {
animation: hotpocket-extension-popup-loader-animation 1s infinite steps(12);
aspect-ratio: 1;
background:
linear-gradient(0deg ,rgb(240 240 240/50%) 30%,#0000 0 70%,rgb(240 240 240/100%) 0) 50%/8% 100%,
linear-gradient(90deg,rgb(240 240 240/25%) 30%,#0000 0 70%,rgb(240 240 240/75% ) 0) 50%/100% 8%;
background-repeat: no-repeat;
border-radius: 50%;
display: grid;
width: 32px;
}
.hotpocket-extension-popup-loader::before,
.hotpocket-extension-popup-loader::after {
background: inherit;
border-radius: 50%;
content: "";
grid-area: 1/1;
opacity: 0.915;
transform: rotate(30deg);
}
.hotpocket-extension-popup-loader::after {
opacity: 0.83;
transform: rotate(60deg);
}
@keyframes hotpocket-extension-popup-loader-animation {
100% {transform: rotate(1turn)}
}
</style> </style>
<div class="hotpocket-extension-popup"> <div class="hotpocket-extension-popup">
<div class="hotpocket-extension-popup-header"> <div class="hotpocket-extension-popup-header">

View File

@ -0,0 +1 @@
<div class="hotpocket-extension-popup-loader"></div>

View File

@ -3,7 +3,7 @@
"default_locale": "en", "default_locale": "en",
"name": "__MSG_extension_name__", "name": "__MSG_extension_name__",
"description": "__MSG_extension_description__", "description": "__MSG_extension_description__",
"version": "25.10.4", "version": "25.10.13",
"icons": { "icons": {
"16": "images/icon-16.png", "16": "images/icon-16.png",
"32": "images/icon-32.png", "32": "images/icon-32.png",

View File

@ -8,12 +8,7 @@ ARG APP_USER_UID
ARG APP_USER_GID ARG APP_USER_GID
ARG IMAGE_ID ARG IMAGE_ID
USER root
# COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ops/bin/*.sh /srv/bin/ # COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ops/bin/*.sh /srv/bin/
RUN chown -R ${APP_USER_UID}:${APP_USER_GID} /srv
USER app
VOLUME ["/srv/node_modules", "/srv/venv"] VOLUME ["/srv/node_modules", "/srv/venv"]
@ -21,5 +16,3 @@ FROM development AS ci
COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ /srv/app/ COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ /srv/app/
COPY --chown=$APP_USER_UID:$APP_USER_GID tls/ /srv/tls/ COPY --chown=$APP_USER_UID:$APP_USER_GID tls/ /srv/tls/
RUN chown -R $APP_USER_UID:$APP_USER_GID /srv

View File

View File

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import enum
class BackendServiceErrorCode(enum.Enum):
INTERNAL = -32000
NOT_FOUND = -32001
ACCESS_DENIED = -32002
INVALID = -32003

View File

@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
from hotpocket_soa.constants import BackendServiceErrorCode
if typing.TYPE_CHECKING:
from django.core.exceptions import ValidationError
VALIDATION_CODE_INVALID = 'invalid'
def get_validation_error_data(validation_error: typing.Any) -> typing.Any: # Heh
if hasattr(validation_error, 'error_dict') is True:
return {
field: [
get_validation_error_data(inner_error)
for inner_error
in inner_errors
]
for field, inner_errors in validation_error.error_dict.items()
}
elif hasattr(validation_error, 'error_list') is True and len(validation_error.error_list) > 1:
return [
get_validation_error_data(inner_error)
for inner_error
in validation_error.error_list
]
elif hasattr(validation_error, 'code') is True:
return validation_error.code
elif isinstance(validation_error, (tuple, list)) is True:
return [
get_validation_error_data(inner_error)
for inner_error
in validation_error
]
elif isinstance(validation_error, dict) is True:
return {
field: [
get_validation_error_data(inner_error)
for inner_error
in inner_errors
]
for field, inner_errors in validation_error.items()
}
else:
return VALIDATION_CODE_INVALID
class BackendServiceError(Exception):
CODE = BackendServiceErrorCode.INTERNAL
def __init__(self, message: str, *args):
super().__init__(message, *args)
self.message = message
self.data: typing.Any = None
if len(args) > 0:
self.data = args[0]
class InternalError(BackendServiceError):
pass
class NotFound(BackendServiceError):
CODE = BackendServiceErrorCode.NOT_FOUND
class AccessDenied(BackendServiceError):
CODE = BackendServiceErrorCode.ACCESS_DENIED
class Invalid(BackendServiceError):
CODE = BackendServiceErrorCode.INVALID
@classmethod
def from_django_validation_error(cls: type[typing.Self],
exception: ValidationError,
message: str | None = None,
) -> typing.Self:
data = get_validation_error_data(exception)
result = cls(message or 'Invalid', data)
result.__cause__ = exception
return result

View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
from .backend import BackendServiceError
class SOAError(Exception):
def __init__(self, code: int, message: str, *args):
super().__init__(code, message, *args)
self.code = code
self.message = message
self.data: typing.Any = None
if len(args) > 0:
self.data = args[0]
@classmethod
def from_backend_error(cls: type[typing.Self], exception: BackendServiceError) -> typing.Self:
result = cls(
exception.CODE.value,
exception.message,
exception.data,
)
result.__cause__ = exception
return result

View File

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
import http
import uuid import uuid
from hotpocket_backend.apps.accounts.services import ( from hotpocket_backend.apps.accounts.services import (
@ -11,6 +12,7 @@ from hotpocket_soa.dto.accounts import (
AccessTokenOut, AccessTokenOut,
AccessTokensQuery, AccessTokensQuery,
) )
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError from .base import ProxyService, SOAError
@ -19,22 +21,18 @@ class AccessTokensService(ProxyService):
class AccessTokensServiceError(SOAError): class AccessTokensServiceError(SOAError):
pass pass
class AccessTokenNotFound(AccessTokensServiceError): class NotFound(AccessTokensServiceError):
pass pass
class AccessTokenAccessDenied(AccessTokensServiceError): class AccessDenied(AccessTokensServiceError):
pass pass
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.backend_access_tokens_service = BackendAccessTokensService() self.backend_access_tokens_service = BackendAccessTokensService()
def wrap_exception(self, exception: Exception) -> Exception: def get_error_class(self) -> type[SOAError]:
new_exception_args = [] return self.AccessTokensServiceError
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.AccessTokensServiceError(*new_exception_args)
def create(self, def create(self,
*, *,
@ -69,16 +67,14 @@ class AccessTokensService(ProxyService):
) )
if result.account_uuid != account_uuid: if result.account_uuid != account_uuid:
raise self.AccessTokenAccessDenied( raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` pk=`{pk}`', f'account_uuid=`{account_uuid}` pk=`{pk}`',
) )
return result return result
except SOAError as exception: except NotFound as exception:
if isinstance(exception.__cause__, BackendAccessTokensService.AccessTokenNotFound) is True: raise self.NotFound.from_backend_error(exception)
raise self.AccessTokenNotFound(*exception.args) from exception
else:
raise
def get_by_key(self, def get_by_key(self,
*, *,
@ -96,16 +92,14 @@ class AccessTokensService(ProxyService):
) )
if result.account_uuid != account_uuid: if result.account_uuid != account_uuid:
raise self.AccessTokenAccessDenied( raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` key=`{key}`', f'account_uuid=`{account_uuid}` key=`{key}`',
) )
return result return result
except SOAError as exception: except NotFound as exception:
if isinstance(exception.__cause__, BackendAccessTokensService.AccessTokenNotFound) is True: raise self.NotFound.from_backend_error(exception)
raise self.AccessTokenNotFound(f'account_uuid=`{account_uuid}` pk=`{key}`') from exception
else:
raise
def search(self, def search(self,
*, *,
@ -124,23 +118,29 @@ class AccessTokensService(ProxyService):
] ]
def delete(self, *, access_token: AccessTokenOut) -> bool: def delete(self, *, access_token: AccessTokenOut) -> bool:
return self.call( try:
self.backend_access_tokens_service, return self.call(
'delete', self.backend_access_tokens_service,
pk=access_token.pk, 'delete',
) pk=access_token.pk,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def update_meta(self, def update_meta(self,
*, *,
access_token: AccessTokenOut, access_token: AccessTokenOut,
update: AccessTokenMetaUpdateIn, update: AccessTokenMetaUpdateIn,
) -> AccessTokenOut: ) -> AccessTokenOut:
return AccessTokenOut.model_validate( try:
self.call( return AccessTokenOut.model_validate(
self.backend_access_tokens_service, self.call(
'update_meta', self.backend_access_tokens_service,
pk=access_token.pk, 'update_meta',
update=update, pk=access_token.pk,
), update=update,
from_attributes=True, ),
) from_attributes=True,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)

View File

@ -7,6 +7,7 @@ from hotpocket_backend.apps.accounts.services import (
AccountsService as BackendAccountsService, AccountsService as BackendAccountsService,
) )
from hotpocket_soa.dto.accounts import AccountOut from hotpocket_soa.dto.accounts import AccountOut
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError from .base import ProxyService, SOAError
@ -15,19 +16,15 @@ class AccountsService(ProxyService):
class AccountsServiceError(SOAError): class AccountsServiceError(SOAError):
pass pass
class AccountNotFound(AccountsServiceError): class NotFound(AccountsServiceError):
pass pass
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.backend_accounts_service = BackendAccountsService() self.backend_accounts_service = BackendAccountsService()
def wrap_exception(self, exception: Exception) -> Exception: def get_error_class(self) -> type[SOAError]:
new_exception_args = [] return self.AccountsServiceError
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.AccountsServiceError(*new_exception_args)
def get(self, *, pk: uuid.UUID) -> AccountOut: def get(self, *, pk: uuid.UUID) -> AccountOut:
try: try:
@ -41,8 +38,5 @@ class AccountsService(ProxyService):
) )
return result return result
except SOAError as exception: except NotFound as exception:
if isinstance(exception.__cause__, BackendAccountsService.AccountNotFound) is True: raise self.NotFound.from_backend_error(exception)
raise self.AccountNotFound(*exception.args) from exception
else:
raise

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import http
import uuid import uuid
from hotpocket_backend.apps.saves.services import ( from hotpocket_backend.apps.saves.services import (
@ -14,6 +15,7 @@ from hotpocket_soa.dto.associations import (
AssociationWithTargetOut, AssociationWithTargetOut,
) )
from hotpocket_soa.dto.saves import SaveOut from hotpocket_soa.dto.saves import SaveOut
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError from .base import ProxyService, SOAError
@ -22,22 +24,18 @@ class AssociationsService(ProxyService):
class AssociationsServiceError(SOAError): class AssociationsServiceError(SOAError):
pass pass
class AssociationNotFound(AssociationsServiceError): class NotFound(AssociationsServiceError):
pass pass
class AssociationAccessDenied(AssociationsServiceError): class AccessDenied(AssociationsServiceError):
pass pass
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.backend_associations_service = BackendAssociationsService() self.backend_associations_service = BackendAssociationsService()
def wrap_exception(self, exception: Exception) -> Exception: def get_error_class(self) -> type[SOAError]:
new_exception_args = [] return self.AssociationsServiceError
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.AssociationsServiceError(*new_exception_args)
def create(self, def create(self,
*, *,
@ -81,19 +79,19 @@ class AssociationsService(ProxyService):
) )
if allow_archived is False and result.archived_at is not None: if allow_archived is False and result.archived_at is not None:
raise self.AssociationNotFound(f'pk=`{pk}`') raise self.NotFound(
http.HTTPStatus.NOT_FOUND.value, f'pk=`{pk}`',
)
if account_uuid is not None and result.account_uuid != account_uuid: if account_uuid is not None and result.account_uuid != account_uuid:
raise self.AssociationAccessDenied( raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` pk=`{pk}`', f'account_uuid=`{account_uuid}` pk=`{pk}`',
) )
return result return result
except SOAError as exception: except NotFound as exception:
if isinstance(exception.__cause__, BackendAssociationsService.AssociationNotFound) is True: raise self.NotFound.from_backend_error(exception)
raise self.AssociationNotFound(*exception.args) from exception
else:
raise
def search(self, def search(self,
*, *,
@ -112,50 +110,65 @@ class AssociationsService(ProxyService):
] ]
def archive(self, *, association: AssociationOut) -> bool: def archive(self, *, association: AssociationOut) -> bool:
return self.call( try:
self.backend_associations_service, return self.call(
'archive', self.backend_associations_service,
pk=association.pk, 'archive',
) pk=association.pk,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def star(self, *, association: AssociationOut) -> AssociationOut: def star(self, *, association: AssociationOut) -> AssociationOut:
return AssociationOut.model_validate( try:
self.call( return AssociationOut.model_validate(
self.backend_associations_service, self.call(
'star', self.backend_associations_service,
pk=association.pk, 'star',
), pk=association.pk,
from_attributes=True, ),
) from_attributes=True,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def unstar(self, *, association: AssociationOut) -> AssociationOut: def unstar(self, *, association: AssociationOut) -> AssociationOut:
return AssociationOut.model_validate( try:
self.call( return AssociationOut.model_validate(
self.backend_associations_service, self.call(
'unstar', self.backend_associations_service,
pk=association.pk, 'unstar',
), pk=association.pk,
from_attributes=True, ),
) from_attributes=True,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def update(self, def update(self,
*, *,
association: AssociationOut, association: AssociationOut,
update: AssociationUpdateIn, update: AssociationUpdateIn,
) -> AssociationOut: ) -> AssociationOut:
return AssociationOut.model_validate( try:
self.call( return AssociationOut.model_validate(
self.backend_associations_service, self.call(
'update', self.backend_associations_service,
pk=association.pk, 'update',
update=update, pk=association.pk,
), update=update,
from_attributes=True, ),
) from_attributes=True,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def delete(self, *, association: AssociationOut) -> bool: def delete(self, *, association: AssociationOut) -> bool:
return self.call( try:
self.backend_associations_service, return self.call(
'delete', self.backend_associations_service,
pk=association.pk, 'delete',
) pk=association.pk,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)

View File

@ -1,12 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
import http
import uuid import uuid
from hotpocket_backend.apps.accounts.services import ( from hotpocket_backend.apps.accounts.services import (
AuthKeysService as BackendAuthKeysService, AuthKeysService as BackendAuthKeysService,
) )
from hotpocket_soa.dto.accounts import AuthKeyOut from hotpocket_soa.dto.accounts import AuthKeyOut
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError from .base import ProxyService, SOAError
@ -15,23 +17,16 @@ class AuthKeysService(ProxyService):
class AuthKeysServiceError(SOAError): class AuthKeysServiceError(SOAError):
pass pass
class AuthKeyNotFound(AuthKeysServiceError): class NotFound(AuthKeysServiceError):
pass pass
class AuthKeyAccessDenied(AuthKeysServiceError): class AccessDenied(AuthKeysServiceError):
pass pass
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.backend_auth_keys_service = BackendAuthKeysService() self.backend_auth_keys_service = BackendAuthKeysService()
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.AuthKeysServiceError(*new_exception_args)
def _check_auth_key_access(self, def _check_auth_key_access(self,
auth_key: AuthKeyOut, auth_key: AuthKeyOut,
account_uuid: uuid.UUID | None, account_uuid: uuid.UUID | None,
@ -70,16 +65,14 @@ class AuthKeysService(ProxyService):
) )
if self._check_auth_key_access(result, account_uuid) is False: if self._check_auth_key_access(result, account_uuid) is False:
raise self.AuthKeyAccessDenied( raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` pk=`{pk}`', f'account_uuid=`{account_uuid}` pk=`{pk}`',
) )
return result return result
except SOAError as exception: except NotFound as exception:
if isinstance(exception.__cause__, BackendAuthKeysService.AuthKeyNotFound) is True: raise self.NotFound.from_backend_error(exception)
raise self.AuthKeyNotFound(*exception.args) from exception
else:
raise
def get_by_key(self, def get_by_key(self,
*, *,
@ -97,13 +90,11 @@ class AuthKeysService(ProxyService):
) )
if self._check_auth_key_access(result, account_uuid) is False: if self._check_auth_key_access(result, account_uuid) is False:
raise self.AuthKeyAccessDenied( raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` key=`{key}`', f'account_uuid=`{account_uuid}` key=`{key}`',
) )
return result return result
except SOAError as exception: except NotFound as exception:
if isinstance(exception.__cause__, BackendAuthKeysService.AuthKeyNotFound) is True: raise self.NotFound.from_backend_error(exception)
raise self.AuthKeyNotFound(*exception.args) from exception
else:
raise

View File

@ -1,16 +1,66 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
import functools
import http
import types
import typing import typing
from hotpocket_soa.exceptions.backend import BackendServiceError
class SOAError(Exception): from hotpocket_soa.exceptions.frontend import SOAError
pass
class Service: class Service:
def wrap_exception(self, exception: Exception) -> Exception: def __getattribute__(self, name: str) -> typing.Any:
return SOAError(exception.args[0]) result = super().__getattribute__(name)
is_service_method = all((
name.startswith('_') is False,
hasattr(Service, name) is False,
isinstance(result, types.MethodType),
getattr(result, '__self__', None) is self,
))
if is_service_method is True:
@functools.wraps(result)
def wrapped_result(*args, **kwargs):
try:
return result(*args, **kwargs)
except Exception as exception:
raise self.wrap_exception(exception) from exception
return wrapped_result
return result
def get_error_class(self) -> type[SOAError]:
return SOAError
def wrap_exception(self, exception: Exception) -> SOAError:
error_class = self.get_error_class()
if isinstance(exception, error_class) is True:
return typing.cast(SOAError, exception)
result = error_class(
http.HTTPStatus.IM_A_TEAPOT.value,
'SOA Error',
*exception.args,
)
if isinstance(exception, BackendServiceError) is True:
baskend_error = typing.cast(BackendServiceError, exception)
result = error_class(
baskend_error.CODE.value,
baskend_error.message,
baskend_error.data,
*baskend_error.args,
)
result.__cause__ = exception
return result
def call(self, *args, **kwargs) -> typing.Any:
raise NotImplementedError('TODO')
class ProxyService(Service): class ProxyService(Service):
@ -18,7 +68,4 @@ class ProxyService(Service):
handler = getattr(service, method, None) handler = getattr(service, method, None)
assert handler is not None, f'Unknown method: method=`{method}`' assert handler is not None, f'Unknown method: method=`{method}`'
try: return handler(*args, **kwargs)
return handler(*args, **kwargs)
except Exception as exception:
raise self.wrap_exception(exception) from exception

View File

@ -15,12 +15,8 @@ class BotService(ProxyService):
super().__init__() super().__init__()
self.backend_associations_service = BackendBotService() self.backend_associations_service = BackendBotService()
def wrap_exception(self, exception: Exception) -> Exception: def get_error_class(self) -> type[SOAError]:
new_exception_args = [] return self.BotServiceError
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.BotServiceError(*new_exception_args)
def is_netloc_banned(self, *, url: str) -> bool: def is_netloc_banned(self, *, url: str) -> bool:
return self.call( return self.call(

View File

@ -18,17 +18,13 @@ class SaveProcessorService(ProxyService):
class SaveProcessorServiceError(SOAError): class SaveProcessorServiceError(SOAError):
pass pass
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.SaveProcessorServiceError(*new_exception_args)
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.backend_save_processor_service = BackendSaveProcessorService() self.backend_save_processor_service = BackendSaveProcessorService()
def get_error_class(self) -> type[SOAError]:
return self.SaveProcessorServiceError
def schedule_process_save(self, *, save: SaveOut) -> AsyncResultOut: def schedule_process_save(self, *, save: SaveOut) -> AsyncResultOut:
result = AsyncResultOut.model_validate( result = AsyncResultOut.model_validate(
self.call( self.call(

View File

@ -7,6 +7,7 @@ from hotpocket_backend.apps.saves.services import (
SavesService as BackendSavesService, SavesService as BackendSavesService,
) )
from hotpocket_soa.dto.saves import SaveIn, SaveOut from hotpocket_soa.dto.saves import SaveIn, SaveOut
from hotpocket_soa.exceptions.backend import Invalid, NotFound
from .base import ProxyService, SOAError from .base import ProxyService, SOAError
@ -15,30 +16,34 @@ class SavesService(ProxyService):
class SavesServiceError(SOAError): class SavesServiceError(SOAError):
pass pass
class SaveNotFound(SavesServiceError): class NotFound(SavesServiceError):
pass pass
def wrap_exception(self, exception: Exception) -> Exception: class Invalid(SavesServiceError):
new_exception_args = [] pass
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.SavesServiceError(*new_exception_args)
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.backend_saves_service = BackendSavesService() self.backend_saves_service = BackendSavesService()
def get_error_class(self) -> type[SOAError]:
return self.SavesServiceError
def create(self, *, account_uuid: uuid.UUID, save: SaveIn) -> SaveOut: def create(self, *, account_uuid: uuid.UUID, save: SaveIn) -> SaveOut:
return SaveOut.model_validate( try:
self.call( return SaveOut.model_validate(
self.backend_saves_service, self.call(
'create', self.backend_saves_service,
account_uuid=account_uuid, 'create',
save=save, account_uuid=account_uuid,
), save=save,
from_attributes=True, ),
) from_attributes=True,
)
except Invalid as exception:
raise self.Invalid(
exception.CODE.value, exception.message, exception.data,
)
def get(self, *, pk: uuid.UUID) -> SaveOut: def get(self, *, pk: uuid.UUID) -> SaveOut:
try: try:
@ -52,8 +57,5 @@ class SavesService(ProxyService):
) )
return result return result
except SOAError as exception: except NotFound as exception:
if isinstance(exception.__cause__, BackendSavesService.SaveNotFound) is True: raise self.NotFound.from_backend_error(exception)
raise self.SaveNotFound(*exception.args) from exception
else:
raise

View File

@ -24,8 +24,8 @@ server {
listen *:443 ssl; listen *:443 ssl;
server_name app.hotpocket.work.bthlabs.net; server_name app.hotpocket.work.bthlabs.net;
ssl_certificate /Users/bilbo/Projects/PLAYG/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.crt; ssl_certificate /Users/bilbo/Projects/HOTPOCKET/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.crt;
ssl_certificate_key /Users/bilbo/Projects/PLAYG/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.key; ssl_certificate_key /Users/bilbo/Projects/HOTPOCKET/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.key;
location / { location / {
# proxy_cache_bypass $http_upgrade; # proxy_cache_bypass $http_upgrade;
@ -71,8 +71,8 @@ server {
listen *:443 ssl; listen *:443 ssl;
server_name admin.hotpocket.work.bthlabs.net; server_name admin.hotpocket.work.bthlabs.net;
ssl_certificate /Users/bilbo/Projects/PLAYG/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.crt; ssl_certificate /Users/bilbo/Projects/HOTPOCKET/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.crt;
ssl_certificate_key /Users/bilbo/Projects/PLAYG/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.key; ssl_certificate_key /Users/bilbo/Projects/HOTPOCKET/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.key;
location / { location / {
# proxy_cache_bypass $http_upgrade; # proxy_cache_bypass $http_upgrade;