Release v1.0.0
Some checks failed
CI / Checks (push) Failing after 13m2s

This commit is contained in:
2025-08-20 21:00:50 +02:00
commit b4338e2769
401 changed files with 23576 additions and 0 deletions

10
services/backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
hotpocket_backend/playground.py
hotpocket_backend/secrets/docker
hotpocket_backend/secrets/metal
hotpocket_backend/settings/docker
hotpocket_backend/settings/metal
hotpocket_backend/static/
node_modules/
run/celery-beat-schedule*
run/*.sqlite
run/uploads/

113
services/backend/Dockerfile Normal file
View File

@@ -0,0 +1,113 @@
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
USER root
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"]
FROM docker-hosted.nexus.bthlabs.pl/hotpocket/base:build-python-20250819-01 AS deployment-build
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_ID
RUN mkdir /srv/app/hotpocket_backend /srv/packages/common /srv/packages/soa
COPY --chown=$APP_USER_UID:$APP_USER_GID backend/hotpocket_backend/ /srv/app/hotpocket_backend/
COPY --chown=$APP_USER_UID:$APP_USER_GID backend/poetry.lock backend/pyproject.toml backend/manage.py backend/README.md /srv/app/
COPY --chown=$APP_USER_UID:$APP_USER_GID packages/common/ /srv/packages/common/
COPY --chown=$APP_USER_UID:$APP_USER_GID packages/soa/ /srv/packages/soa/
RUN poetry install --only main,deployment && \
minify -i --css-precision 0 --js-precision 0 --js-version 2022 hotpocket_backend/apps/ui/static/ui/css/hotpocket-backend*.css hotpocket_backend/apps/ui/static/ui/js/hotpocket*.js && \
./manage.py collectstatic --settings hotpocket_backend.settings.deployment.build --noinput && \
find hotpocket_backend/static/ -name "*.map*" -delete && \
rm -f hotpocket_backend/settings/deployment/build.py && \
rm -rf node_modules/
FROM docker-hosted.nexus.bthlabs.pl/hotpocket/base:base-20250819-01 AS deployment-base
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_ID
ENV HOTPOCKET_BACKEND_IMAGE_ID=${IMAGE_ID}
ENV PYTHONPATH="/srv/local"
COPY --from=deployment-build /srv/app /srv/app
COPY --from=deployment-build /srv/packages /srv/packages
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/deployment/gunicorn.conf.py backend/ops/deployment/gunicorn.logging.conf /srv/lib/
RUN chown -R $APP_USER_UID:$APP_USER_GID /srv
USER root
RUN apt-get update && \
apt-get install -y libpq5 dumb-init && \
apt-get clean autoclean && \
apt-get autoremove --yes && \
rm -rf /var/lib/apt /var/lib/dpkg && \
rm -rf /home/app/.cache
USER app
ENTRYPOINT ["/srv/bin/entrypoint-deployment.sh"]
CMD ["/srv/venv/bin/gunicorn", "-c", "/srv/lib/gunicorn.conf.py", "hotpocket_backend.wsgi:application"]
FROM deployment-base AS deployment
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_ID
ENV DJANGO_SETTINGS_MODULE=hotpocket_backend.settings.deployment.webapp
ENV HOTPOCKET_BACKEND_ENV=deployment
ENV HOTPOCKET_BACKEND_APP=webapp
VOLUME ["/srv/run", "/srv/uploads"]
FROM deployment-base AS aio
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_ID
ENV DJANGO_SETTINGS_MODULE=hotpocket_backend.settings.aio
ENV HOTPOCKET_BACKEND_ENV=aio
ENV HOTPOCKET_BACKEND_APP=webapp
ENV HOTPOCKET_BACKEND_DEBUG=false
ENV HOTPOCKET_BACKEND_DATABASE_ENGINE=django.db.backends.sqlite3
ENV HOTPOCKET_BACKEND_DATABASE_NAME=/srv/run/hotpocket-backend-aio.sqlite
ENV HOTPOCKET_BACKEND_DATABASE_USER=
ENV HOTPOCKET_BACKEND_DATABASE_PASSWORD=
ENV HOTPOCKET_BACKEND_DATABASE_HOST=
ENV HOTPOCKET_BACKEND_DATABASE_PORT=
ENV HOTPOCKET_BACKEND_CELERY_IGNORE_RESULT=true
ENV HOTPOCKET_BACKEND_CELERY_ALWAYS_EAGER=true
ENV HOTPOCKET_BACKEND_GUNICORN_WORKERS=2
ENV HOTPOCKET_BACKEND_RUN_MIGRATIONS=true
ENV HOTPOCKET_BACKEND_UPLOADS_PATH=/srv/run/uploads
VOLUME ["/srv/run"]
FROM development AS ci
COPY --chown=$APP_USER_UID:$APP_USER_GID backend/ /srv/app/
COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ /srv/packages/
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 && \
ln -s /srv/app/ops/docker/secrets /srv/app/hotpocket_backend/secrets/docker && \
chown -R $APP_USER_UID:$APP_USER_GID /srv

View File

@@ -0,0 +1,3 @@
# HotPocket by BTHLabs
This repository contains the _HotPocket Backend_ project.

View File

@@ -0,0 +1,30 @@
services:
webapp:
build:
context: ".."
dockerfile: "backend/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:aio-local"
environment:
DJANGO_SETTINGS_MODULE: "hotpocket_backend.settings.aio"
HOTPOCKET_BACKEND_ENV: "${HOTPOCKET_BACKEND_ENV:-aio}"
HOTPOCKET_BACKEND_APP: "webapp"
HOTPOCKET_BACKEND_DEBUG: "false"
HOTPOCKET_BACKEND_SECRET_KEY: "thisisntright"
HOTPOCKET_BACKEND_DATABASE_PAYLOAD: '{"engine":"django.db.backends.sqlite3","name":"/srv/run/hotpocket-backend-aio.sqlite"}'
HOTPOCKET_BACKEND_CELERY_IGNORE_RESULT: "true"
HOTPOCKET_BACKEND_CELERY_ALWAYS_EAGER: "true"
HOTPOCKET_BACKEND_GUNICORN_WORKERS: "2"
HOTPOCKET_BACKEND_INITIAL_ACCOUNT_USERNAME: "hotpocket"
HOTPOCKET_BACKEND_INITIAL_ACCOUNT_PASSWORD: "hotpocketm4st3r"
HOTPOCKET_BACKEND_RUN_MIGRATIONS: "true"
HOTPOCKET_BACKEND_UPLOADS_PATH: "/srv/run/uploads"
volumes:
- "./run:/srv/run"
networks:
default:
aliases:
- "backend-webapp.hotpocket.work.bthlabs.net"
restart: "unless-stopped"
stdin_open: true
tty: true

View File

@@ -0,0 +1,31 @@
services:
backend-ci:
build:
context: ".."
dockerfile: "backend/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:ci-local"
command: "echo 'NOOP'"
environment:
PYTHONBREAKPOINT: "ipdb.set_trace"
PYTHONPATH: "/srv/packages/common"
DJANGO_SETTINGS_MODULE: "hotpocket_backend.settings.docker.webapp"
DJANGO_TESTING_SETTINGS_MODULE: "hotpocket_backend.settings.docker.testing"
HOTPOCKET_BACKEND_ENV: "docker"
HOTPOCKET_BACKEND_APP: "webapp"
POSTGRES_HOSTPORT: "${POSTGRES_HOST:-postgres.hotpocket.work.bthlabs.net}:${POSTGRES_PORT:-5432}"
RABBITMQ_HOSTPORT: "${RABBITMQ_HOST:-rabbitmq.hotpocket.work.bthlabs.net}:${RABBITMQ_PORT:-5672}"
# REQUESTS_CA_BUNDLE: "/srv/tls/requests_ca_bundle.pem"
RUN_POETRY_INSTALL: "true"
RUN_YARN_INSTALL: "true"
SETUP_BACKEND: "true"
SETUP_FRONTEND: "true"
volumes:
- "backend_venv:/srv/venv"
- "backend_node_modules:/srv/node_modules"
restart: "no"
stdin_open: true
tty: true
depends_on:
- "postgres"
- "rabbitmq"

View File

@@ -0,0 +1,182 @@
services:
backend-management:
build:
context: ".."
dockerfile: "backend/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:local"
command: "echo 'NOOP'"
environment: &backend-env
PYTHONBREAKPOINT: "ipdb.set_trace"
PYTHONPATH: "/srv/packages/common"
DJANGO_SETTINGS_MODULE: "hotpocket_backend.settings.docker.webapp"
DJANGO_TESTING_SETTINGS_MODULE: "hotpocket_backend.settings.docker.testing"
HOTPOCKET_BACKEND_ENV: "${HOTPOCKET_BACKEND_ENV:-docker}"
HOTPOCKET_BACKEND_APP: "webapp"
POSTGRES_HOSTPORT: "${POSTGRES_HOST:-postgres.hotpocket.work.bthlabs.net}:${POSTGRES_PORT:-5432}"
RABBITMQ_HOSTPORT: "${RABBITMQ_HOST:-rabbitmq.hotpocket.work.bthlabs.net}:${RABBITMQ_PORT:-5672}"
KEYCLOAK_HOSTPORT: "${KEYCLOAK_HOST:-auth.hotpocket.work.bthlabs.net}:${KEYCLOAK_PORT:-8080}"
REQUESTS_CA_BUNDLE: "/srv/tls/requests_ca_bundle.pem"
RUN_POETRY_INSTALL: "true"
RUN_YARN_INSTALL: "true"
SETUP_BACKEND: "true"
SETUP_FRONTEND: "true"
volumes:
- "backend_venv:/srv/venv"
- "backend_node_modules:/srv/node_modules"
- ".:/srv/app"
- "./ops/docker/settings:/srv/app/hotpocket_backend/settings/docker"
- "./ops/docker/secrets:/srv/app/hotpocket_backend/secrets/docker"
- "../packages:/srv/packages"
- "../tls:/srv/tls"
restart: "no"
stdin_open: true
tty: true
depends_on:
- "postgres"
- "keycloak"
- "rabbitmq"
backend-webapp:
build:
context: ".."
dockerfile: "backend/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:local"
command:
- "./manage.py"
- "runserver"
- "0.0.0.0:8000"
environment:
<<: *backend-env
RUN_POETRY_INSTALL: "nope"
RUN_YARN_INSTALL: "nope"
volumes:
- "backend_venv:/srv/venv"
- "backend_node_modules:/srv/node_modules"
- ".:/srv/app"
- "./ops/docker/settings:/srv/app/hotpocket_backend/settings/docker"
- "./ops/docker/secrets:/srv/app/hotpocket_backend/secrets/docker"
- "../packages:/srv/packages"
- "../tls:/srv/tls"
networks:
default:
aliases:
- "backend-webapp.hotpocket.work.bthlabs.net"
restart: "unless-stopped"
stdin_open: true
tty: true
depends_on:
- "postgres"
- "keycloak"
- "rabbitmq"
backend-admin:
build:
context: ".."
dockerfile: "backend/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:local"
command:
- "./manage.py"
- "runserver"
- "0.0.0.0:8000"
environment:
<<: *backend-env
DJANGO_SETTINGS_MODULE: "hotpocket_backend.settings.docker.admin"
HOTPOCKET_BACKEND_APP: "admin"
RUN_POETRY_INSTALL: "nope"
RUN_YARN_INSTALL: "nope"
volumes:
- "backend_venv:/srv/venv"
- "backend_node_modules:/srv/node_modules"
- ".:/srv/app"
- "./ops/docker/settings:/srv/app/hotpocket_backend/settings/docker"
- "./ops/docker/secrets:/srv/app/hotpocket_backend/secrets/docker"
- "../packages:/srv/packages"
- "../tls:/srv/tls"
networks:
default:
aliases:
- "backend-admin.hotpocket.work.bthlabs.net"
restart: "unless-stopped"
stdin_open: true
tty: true
depends_on:
- "postgres"
- "keycloak"
- "rabbitmq"
backend-celery-worker:
build:
context: ".."
dockerfile: "backend/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:local"
command:
- "celery"
- "-A"
- "hotpocket_backend.celery:app"
- "worker"
- "--loglevel=INFO"
- "-Q"
- "celery,webapp"
- "-c"
- "4"
environment:
<<: *backend-env
RUN_POETRY_INSTALL: "nope"
RUN_YARN_INSTALL: "nope"
volumes:
- "backend_venv:/srv/venv"
- "backend_node_modules:/srv/node_modules"
- ".:/srv/app"
- "./ops/docker/settings:/srv/app/hotpocket_backend/settings/docker"
- "./ops/docker/secrets:/srv/app/hotpocket_backend/secrets/docker"
- "../packages:/srv/packages"
- "../tls:/srv/tls"
restart: "unless-stopped"
stdin_open: true
tty: true
depends_on:
- "postgres"
- "keycloak"
- "rabbitmq"
backend-celery-beat:
build:
context: ".."
dockerfile: "backend/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:local"
command:
- "celery"
- "-A"
- "hotpocket_backend.celery:app"
- "beat"
- "--loglevel=INFO"
- "-s"
- "/srv/app/run/celery-beat-schedule-docker"
environment:
<<: *backend-env
RUN_POETRY_INSTALL: "nope"
RUN_YARN_INSTALL: "nope"
volumes:
- "backend_venv:/srv/venv"
- "backend_node_modules:/srv/node_modules"
- ".:/srv/app"
- "./ops/docker/settings:/srv/app/hotpocket_backend/settings/docker"
- "./ops/docker/secrets:/srv/app/hotpocket_backend/secrets/docker"
- "../packages:/srv/packages"
- "../tls:/srv/tls"
restart: "unless-stopped"
stdin_open: true
tty: true
depends_on:
- "postgres"
- "keycloak"
- "rabbitmq"
volumes:
backend_venv:
backend_node_modules:

View File

@@ -0,0 +1,67 @@
// eslint.config.js
import js from '@eslint/js';
import {defineConfig} from 'eslint/config';
import globals from 'globals';
export default defineConfig([
{
files: [
'eslint.config.js',
'hotpocket_backend/apps/ui/static/ui/js/hotpocket.*.js',
],
plugins: {
js,
},
extends: ['js/recommended'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.browser,
},
},
rules: {
'no-undef': 'error',
'quotes': [
'error',
'single',
{'avoidEscape': true, 'allowTemplateLiterals': true},
],
'no-unused-vars': ['error', {'args': 'none'}],
'no-console': ['error', {'allow': ['warn', 'error']}],
'no-empty': ['error', {'allowEmptyCatch': true}],
'array-bracket-spacing': ['error', 'never'],
'block-spacing': ['error', 'always'],
'brace-style': ['error', '1tbs', {'allowSingleLine': true}],
'camelcase': ['error', {'properties': 'never'}],
'comma-dangle': ['error', 'always-multiline'],
'comma-spacing': ['error', {'before': false, 'after': true}],
'comma-style': ['error', 'last'],
'computed-property-spacing': ['error', 'never'],
'key-spacing': [
'error', {'beforeColon': false, 'afterColon': true, 'mode': 'strict'},
],
'keyword-spacing': ['error', { 'before': true, 'after': true }],
'linebreak-style': ['error', 'unix'],
'max-len': ['error', 120],
'no-multiple-empty-lines': 'error',
'no-spaced-func': 'error',
'no-trailing-spaces': 'error',
'no-unreachable': 'warn',
'no-whitespace-before-property': 'error',
'object-curly-spacing': 'off',
'one-var-declaration-per-line': ['error', 'always'],
'one-var': ['error', 'never'],
'semi-spacing': ['error', {'before': false, 'after': true}],
'semi': ['error', 'always'],
'space-before-function-paren': ['error', 'always'],
'space-before-blocks': ['error', 'always'],
'space-in-parens': ['error', 'never'],
'space-infix-ops': 'error',
'unicode-bom': ['error', 'never'],
'no-useless-escape': 'off',
'class-methods-use-this': 'off',
'no-invalid-this': 'off',
},
},
]);

View File

@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from . import _meta
from .celery import app as celery_app
__all__ = ('celery_app',)
__version__ = _meta.version

View File

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

View File

@@ -0,0 +1 @@
from .account import AccountAdmin # noqa: F401

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from hotpocket_backend.apps.accounts.models import Account
class AccountAdmin(UserAdmin):
list_display = (*UserAdmin.list_display, 'is_active')
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
admin.site.register(Account, AccountAdmin)

View File

@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
label = 'accounts'
name = 'hotpocket_backend.apps.accounts'
verbose_name = _('Accounts')

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from hotpocket_backend.apps.accounts.types import PAccount
def is_authenticated_and_active_account(account: PAccount) -> bool:
return all((
account.is_authenticated,
account.is_active,
))

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.http import HttpRequest
from hotpocket_backend.apps.core.conf import settings
def hotpocket_oidc(request: HttpRequest) -> dict:
return {
'HOTPOCKET_OIDC_IS_ENABLED': settings.SECRETS.OIDC.is_enabled,
'HOTPOCKET_OIDC_DISPLAY_NAME': settings.SECRETS.OIDC.display_name,
}
def auth_settings(request: HttpRequest) -> dict:
return {
'MODEL_AUTH_IS_DISABLED': settings.MODEL_AUTH_IS_DISABLED,
}

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.decorators import user_passes_test
from .checks import is_authenticated_and_active_account
def account_required(function=None,
redirect_field_name=REDIRECT_FIELD_NAME,
login_url=None,
):
actual_decorator = user_passes_test(
is_authenticated_and_active_account,
login_url=login_url,
redirect_field_name=redirect_field_name,
)
if function:
return actual_decorator(function)
return actual_decorator

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from argparse import ArgumentParser
import logging
from django.core.management import BaseCommand
import django.db
from hotpocket_backend.apps.accounts.models import Account
LOGGER = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Create initial Account'
def add_arguments(self, parser: ArgumentParser):
parser.add_argument(
'username',
help='Username for the Account',
)
parser.add_argument(
'password',
help='Password for the Account',
)
parser.add_argument(
'-d', '--dry-run', action='store_true', default=False,
help='Dry run',
)
def handle(self, *args, **options):
LOGGER.debug('args=`%s` options=`%s`', args, options)
username = options.get('username')
password = options.get('password')
dry_run = options.get('dry_run', False)
with django.db.transaction.atomic():
current_account = Account.objects.filter(username=username).first()
if current_account is not None:
LOGGER.info(
'Account already exists: account=`%s`', current_account,
)
return
account = Account.objects.create(
username=username,
first_name=username,
is_superuser=True,
is_staff=True,
)
account.set_password(password)
account.save()
LOGGER.info(
'Account created: account=`%s`', account,
)
if dry_run is True:
raise RuntimeError('DRY RUN')

View File

@@ -0,0 +1,44 @@
# Generated by Django 5.2.3 on 2025-06-30 20:37
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
import uuid6
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='Account',
fields=[
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('id', models.UUIDField(default=uuid6.uuid7, primary_key=True, serialize=False)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='account_set', related_query_name='account', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='account_set', related_query_name='account', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'Account',
'verbose_name_plural': 'Accounts',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-07-10 19:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='account',
name='settings',
field=models.JSONField(default=dict),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-08-02 19:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_account_settings'),
]
operations = [
migrations.AddField(
model_name='account',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.3 on 2025-08-08 19:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_account_updated_at'),
]
operations = [
migrations.AlterField(
model_name='account',
name='settings',
field=models.JSONField(db_column='settings', default=dict),
),
migrations.RenameField(
model_name='account',
old_name='settings',
new_name='raw_settings',
),
]

View File

@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib.auth.mixins import AccessMixin
from .checks import is_authenticated_and_active_account
class AccountRequiredMixin(AccessMixin):
def dispatch(self, request, *args, **kwargs):
if is_authenticated_and_active_account(self.request.user) is False:
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)

View File

@@ -0,0 +1 @@
from .account import Account # noqa: F401

View File

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib.auth.models import (
AbstractUser,
Group,
Permission,
UserManager,
)
from django.db import models
from django.utils.translation import gettext_lazy as _
import uuid6
class ActiveAccountsManager(models.Manager):
def get_queryset(self) -> models.QuerySet:
return super().get_queryset().filter(
is_active=True,
is_staff=False,
)
class Account(AbstractUser):
id = models.UUIDField(
null=False, blank=False, default=uuid6.uuid7, primary_key=True,
)
groups = models.ManyToManyField(
Group,
verbose_name=_('groups'),
blank=True,
help_text=_(
(
'The groups this user belongs to. A user will get all '
'permissions granted to each of their groups.'
),
),
related_name='account_set',
related_query_name='account',
)
user_permissions = models.ManyToManyField(
Permission,
verbose_name=_('user permissions'),
blank=True,
help_text=_('Specific permissions for this user.'),
related_name='account_set',
related_query_name='account',
)
raw_settings = models.JSONField(default=dict, db_column='settings')
updated_at = models.DateTimeField(auto_now=True)
objects = UserManager()
active_accounts = ActiveAccountsManager()
class Meta:
verbose_name = _('Account')
verbose_name_plural = _('Accounts')
def __str__(self) -> str:
return f'<Account pk={self.pk} username={self.username}>'
@property
def settings(self) -> dict:
result = {**self.raw_settings}
auto_load_embeds = result.get('auto_load_embeds', None)
if isinstance(auto_load_embeds, str) is True:
result['auto_load_embeds'] = (auto_load_embeds == 'True')
else:
result['auto_load_embeds'] = auto_load_embeds
return result

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
from social_core.backends.open_id_connect import OpenIdConnectAuth
LOGGER = logging.getLogger(__name__)
class HotPocketOpenIdConnectAuth(OpenIdConnectAuth):
name = 'hotpocket_oidc'
def _get_roles_from_response(response) -> list[str]:
from hotpocket_backend.apps.core.conf import settings
return response.\
get('resource_access', {}).\
get(settings.SECRETS.OIDC.key, {}).\
get('roles', [])
def set_user_is_staff(strategy, details, response, user=None, *args, **kwargs):
if user is None:
return None
roles = _get_roles_from_response(response)
user.is_staff = 'staff' in roles
strategy.storage.user.changed(user)
def set_user_is_superuser(strategy, details, response, user=None, *args, **kwargs):
if user is None:
return None
roles = _get_roles_from_response(response)
user.is_superuser = 'superuser' in roles
strategy.storage.user.changed(user)

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
import uuid
class PAccount(typing.Protocol):
id: uuid.UUID
username: str
first_name: str
last_name: str
email: str
is_active: bool
is_anonymous: bool
is_authenticated: bool
pk: uuid.UUID

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib import admin
from django.contrib.admin.apps import AdminConfig
from django.utils.translation import gettext_lazy as _
class HotPocketAdminSite(admin.AdminSite):
site_header = 'HotPocket'
site_title = 'HotPocket by BTHLabs'
index_title = _('Home')
login_template = 'core/admin/login.html'
class HotPocketAdminConfig(AdminConfig):
default_site = 'hotpocket_backend.apps.admin.HotPocketAdminSite'

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.apps import AppConfig
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext_lazy as _
class BotConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
label = 'bot'
name = 'hotpocket_backend.apps.bot'
verbose_name = _('Bot')
def ready(self):
super().ready()
try:
from hotpocket_backend.apps.bot import conf
conf.bot_settings = conf.from_django_settings()
except Exception as exception:
raise ImproperlyConfigured('Invalid bot settings') from exception

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import dataclasses
from .types import PStrategy
DEFAULT_STRATEGY = 'hotpocket_backend.apps.bot.strategy.basic:BasicStrategy'
DEFAULT_BANNED_HOSTNAMES = [
# YT returns dummy data when I try to fetch the page and extract
# metadata. I'd have to use Google APIs for that and it's 11:30 PM...
'youtube.com',
'youtu.be',
# Reddit's response is too generic to pull any useful info from it.
# Since they forced Apollo to shut down, I refuse to even think about
# interacting with their API :P.
'reddit.com',
# Twitter, amirite?
'twitter.com',
't.co',
'x.com',
]
@dataclasses.dataclass(kw_only=True)
class Settings:
STRATEGY: str
BANNED_HOSTNAMES: list[str]
def get_strategy(self, *, url: str) -> PStrategy:
from hotpocket_common.loader import load_module_attribute
strategy = load_module_attribute(self.STRATEGY)
return strategy(url)
def from_django_settings() -> Settings:
from django.conf import settings
return Settings(
STRATEGY=getattr(
settings,
'HOTPOCKET_BOT_STRATEGY',
DEFAULT_STRATEGY,
),
BANNED_HOSTNAMES=getattr(
settings,
'HOTPOCKET_BOT_BANNED_HOSTNAMES',
DEFAULT_BANNED_HOSTNAMES,
),
)
bot_settings: Settings = None # type: ignore[assignment]

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import pydantic
class FetchResult(pydantic.BaseModel):
status_code: int
content: bytes
content_type: str | None
encoding: str

View File

@@ -0,0 +1 @@
from .bot import BotService # noqa: F401

View File

@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from hotpocket_backend.apps.bot.conf import bot_settings
from hotpocket_backend.apps.bot.types import PStrategy
from hotpocket_soa.dto import BotResultOut
class BotService:
def is_netloc_banned(self, *, url: str) -> bool:
strategy: PStrategy = bot_settings.get_strategy(url=url)
return strategy.is_netloc_banned()
def handle(self, *, url: str) -> BotResultOut:
strategy: PStrategy = bot_settings.get_strategy(url=url)
return strategy.run()

View File

@@ -0,0 +1 @@
from .basic import BasicStrategy # noqa: F401

View File

@@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import abc
import logging
from pyquery import PyQuery
import requests
from hotpocket_backend._meta import version as backend_version
from hotpocket_backend.apps.bot.conf import bot_settings
from hotpocket_backend.apps.bot.dto.strategy import FetchResult
from hotpocket_common.url import URL
from hotpocket_soa.dto import BotResultOut
LOGGER = logging.getLogger(__name__)
class Strategy(abc.ABC):
class StrategyError(Exception):
pass
class FetchError(StrategyError):
pass
class RuntimeError(StrategyError):
pass
USER_AGENT = (
'Mozilla/5.0 '
'('
'compatible; '
f'BTHLabsHotPocketBot/{backend_version}; '
'+https://hotpocket.app/bot.txt'
')'
)
TITLE_TAG_SELECTORS = [
'head > meta[name=title]',
'head > meta[property="og:title"]',
'head > title',
]
DESCRIPTION_TAG_SELECTORS = [
'head > meta[property="og:description"]',
'head > meta[name=description]',
]
def __init__(self, url: str):
super().__init__()
self.url = url
self.parsed_url = URL(self.url)
self.logger = self.get_logger()
def get_logger(self) -> logging.Logger:
return LOGGER.getChild(self.__class__.__name__)
def is_netloc_banned(self) -> bool:
result = False
for banned_netloc in bot_settings.BANNED_HOSTNAMES:
hostname = self.parsed_url.hostname
if hostname is not None and hostname.endswith(banned_netloc) is True:
result = True
break
return result
def fetch(self, url: str) -> FetchResult:
try:
response = requests.request(
'GET',
url,
headers={
'User-Agent': self.USER_AGENT,
},
)
response.raise_for_status()
return FetchResult.model_validate(dict(
status_code=response.status_code,
content=response.content,
content_type=response.headers.get('Content-Type', None),
encoding=response.encoding or response.apparent_encoding,
))
except Exception as exception:
self.logger.error(
'Fetch error: %s', exception, exc_info=True,
)
raise self.FetchError() from exception
def extract_title_and_description_from_html(self, content: str) -> tuple[str | None, str | None]:
dom = PyQuery(content)
title: str | None = None
description: str | None = None
for selector in self.TITLE_TAG_SELECTORS:
title_tags = dom.find(selector)
if len(title_tags) > 0:
title_tag = PyQuery(title_tags[0])
if title_tag.is_('meta'):
title = title_tag.attr('content')
else:
title = title_tag.text()
break
for selector in self.DESCRIPTION_TAG_SELECTORS:
description_tags = dom.find(selector)
if len(description_tags) > 0:
description = PyQuery(description_tags[0]).attr('content')
break
if description is None:
try:
description = PyQuery(dom.find('p')[0]).text()
except IndexError:
pass
return (
title.strip() or None
if title is not None
else None,
description.strip() or None
if description is not None
else None,
)
def run(self) -> BotResultOut:
result = BotResultOut.model_validate(dict(
title=None,
description=None,
is_netloc_banned=False,
))
result.is_netloc_banned = self.is_netloc_banned()
if result.is_netloc_banned is False:
fetch_result = self.fetch(self.url)
try:
assert fetch_result.content is not None, (
'Received empty content'
)
assert fetch_result.content_type is not None, (
'Unable to determine the content type'
)
assert fetch_result.content_type.startswith('text/html') is True, (
f'Unsupported content type: `{fetch_result.content_type}`'
)
except AssertionError as exception:
self.logger.error(
'Unprocessable fetch result: %s', exception, exc_info=exception,
)
raise self.RuntimeError(exception.args[0]) from exception
try:
decoded_content = fetch_result.content.decode(fetch_result.encoding)
title, description = self.extract_title_and_description_from_html(
decoded_content,
)
result.title = title
result.description = description
except Exception as exception:
self.logger.error(
'Processing error: %s', exception, exc_info=exception,
)
raise self.RuntimeError() from exception
else:
self.logger.debug('Skipping banned netloc: url=`%s`', self.url)
return result

View File

@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from .base import Strategy
class BasicStrategy(Strategy):
pass

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
from hotpocket_soa.dto import BotResultOut
class PStrategy(typing.Protocol):
def __init__(self, url: str):
...
def is_netloc_banned(self) -> bool:
...
def run(self) -> BotResultOut:
...

View File

@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
label = 'core'
name = 'hotpocket_backend.apps.core'
verbose_name = _('Core')

View File

@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.conf import settings as django_settings
from .types import PSettings
settings: PSettings = django_settings

View File

@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import contextvars
from hotpocket_common.constants import NULL_UUID
REQUEST_ID: contextvars.ContextVar[str] = contextvars.ContextVar(
'request_id', default=str(NULL_UUID),
)
def get_request_id() -> str:
return REQUEST_ID.get()

View File

@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
from hotpocket_backend.apps.core.context import get_request_id
class RequestIDFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = get_request_id()
return True

View File

@@ -0,0 +1 @@
from .request_id import RequestIDMiddleware # noqa: F401

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import uuid
from django.http import HttpHeaders, HttpRequest
from hotpocket_backend.apps.core.context import REQUEST_ID
class RequestIDMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpHeaders:
request_id = str(uuid.uuid4())
REQUEST_ID.set(request_id)
response = self.get_response(request)
response["X-RequestID"] = REQUEST_ID.get()
return response

View File

@@ -0,0 +1 @@
from .base import Model # noqa: F401

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.db import models
from django.utils.timezone import now
import uuid6
class Model(models.Model):
id = models.UUIDField(
primary_key=True,
null=False,
default=uuid6.uuid7,
editable=False,
)
account_uuid = models.UUIDField(
blank=False, null=False, default=None, db_index=True,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
deleted_at = models.DateTimeField(
blank=True,
null=True,
default=None,
db_index=True,
editable=False,
)
class Meta:
abstract = True
@property
def is_active(self) -> bool:
return self.deleted_at is None
def save(self,
force_insert=False,
force_update=False,
using=None,
update_fields=None,
):
self.full_clean()
super().save(
force_insert=force_insert,
force_update=force_update,
using=using,
update_fields=update_fields,
)
def soft_delete(self, save=True) -> None:
self.deleted_at = now()
if save is True:
self.save()

View File

@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
from hotpocket_common.loader import load_module_attribute
from .conf import settings
def get_adapter(setting: str, default: str) -> typing.Any:
import_path = getattr(settings, setting, default)
return load_module_attribute(import_path)

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
from celery import shared_task
LOGGER = logging.getLogger(__name__)
@shared_task
def ping():
LOGGER.info('PONG')
@shared_task(bind=True)
def debug_request(self):
LOGGER.warning(
'request.id=`%s` request.properties=`%s`',
self.request.id,
self.request.properties,
)

View File

@@ -0,0 +1,83 @@
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% block title %}{% if form.errors %}{% translate "Error:" %} {% endif %}{{ block.super }}{% endblock %}
{% block extrastyle %}{{ block.super }}<link rel="stylesheet" href="{% static "admin/css/login.css" %}">
{{ form.media }}
{% endblock %}
{% block bodyclass %}{{ block.super }} login{% endblock %}
{% block usertools %}{% endblock %}
{% block nav-global %}{% endblock %}
{% block nav-sidebar %}{% endblock %}
{% block content_title %}{% endblock %}
{% block nav-breadcrumbs %}{% endblock %}
{% block content %}
{% if form.errors and not form.non_field_errors %}
<p class="errornote">
{% blocktranslate count counter=form.errors.items|length %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktranslate %}
</p>
{% endif %}
{% if form.non_field_errors %}
{% for error in form.non_field_errors %}
<p class="errornote">
{{ error }}
</p>
{% endfor %}
{% endif %}
<div id="content-main">
{% if user.is_authenticated %}
<p class="errornote">
{% blocktranslate trimmed %}
You are authenticated as {{ username }}, but are not authorized to
access this page. Would you like to login to a different account?
{% endblocktranslate %}
</p>
{% endif %}
<form action="{{ app_path }}" method="post" id="login-form">{% csrf_token %}
{% if not MODEL_AUTH_IS_DISABLED %}
<div class="form-row">
{{ form.username.errors }}
{{ form.username.label_tag }} {{ form.username }}
</div>
<div class="form-row">
{{ form.password.errors }}
{{ form.password.label_tag }} {{ form.password }}
<input type="hidden" name="next" value="{{ next }}">
</div>
{% url 'admin_password_reset' as password_reset_url %}
{% if password_reset_url %}
<div class="password-reset-link">
<a href="{{ password_reset_url }}">{% translate 'Forgotten your login credentials?' %}</a>
</div>
{% endif %}
<div class="submit-row">
<input type="submit" value="{% translate 'Log in' %}">
</div>
{% endif %}
{% if HOTPOCKET_OIDC_IS_ENABLED %}
<div class="submit-row">
<a
class="button"
href="{% url 'social:begin' 'hotpocket_oidc' %}"
style="display: block;"
>
{% blocktranslate %}Log in with {{ HOTPOCKET_OIDC_DISPLAY_NAME }}{% endblocktranslate %}
</a>
</div>
{% endif %}
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import pathlib
import typing
from hotpocket_backend.secrets.admin import AdminSecrets
from hotpocket_backend.secrets.webapp import WebAppSecrets
from hotpocket_common.constants import App, Env
class PSettings(typing.Protocol):
DEBUG: bool
TESTING: bool
ALLOWED_HOSTS: list[str]
SECRET_KEY: str
APP: App
ENV: Env
SECRETS: AdminSecrets | WebAppSecrets
MODEL_AUTH_IS_DISABLED: bool
SITE_TITLE: str
SITE_SHORT_TITLE: str
IMAGE_ID: str
SAVES_SAVE_ADAPTER: str
SAVES_ASSOCIATION_ADAPTER: str
UPLOADS_PATH: pathlib.Path

View File

@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class HTMXConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
label = 'htmx'
name = 'hotpocket_backend.apps.htmx'
verbose_name = _('HTMX')

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib.messages.constants import ( # noqa: F401
DEBUG,
DEFAULT_TAGS,
ERROR,
INFO,
SUCCESS,
WARNING,
)
from django.http import HttpRequest, HttpResponse
from django_htmx.http import trigger_client_event
def add_htmx_message(*,
request: HttpRequest,
response: HttpResponse,
level: str,
message: str,
extra_tags: str = '',
fail_silently: bool = False,
):
if not request.htmx:
if fail_silently is False:
raise RuntimeError(
"This doesn't look like an HTMX request: request=`%s`",
request,
)
else:
trigger_client_event(
response,
'HotPocket:UI:Messages:addMessage',
{
'level': DEFAULT_TAGS.get(level, DEFAULT_TAGS[INFO]),
'message': message,
'extra_tags': extra_tags,
},
after='swap',
)

View File

@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import uuid
from django.db import models
from hotpocket_backend.apps.saves.models import Save
from hotpocket_common.constants import AssociationsSearchMode
from hotpocket_soa.dto.associations import AssociationsQuery
class SaveAdapter:
def get_for_processing(self, *, pk: uuid.UUID) -> Save | None:
return Save.active_objects.get(pk=pk)
class AssociationAdapter:
def get_search_term_filters(self, *, query: AssociationsQuery) -> list[models.Q]:
# I suck at naming things, LOL.
result = [
(
models.Q(target__url__icontains=query.search)
|
models.Q(target_title__icontains=query.search)
|
models.Q(target__title__icontains=query.search)
|
models.Q(target_description__icontains=query.search)
|
models.Q(target__description__icontains=query.search)
),
]
return result
def get_search_filters(self, *, query: AssociationsQuery) -> list[models.Q]:
result = [
models.Q(account_uuid=query.account_uuid),
models.Q(target__deleted_at__isnull=True),
]
if query.mode == AssociationsSearchMode.ARCHIVED:
result.append(models.Q(archived_at__isnull=False))
else:
result.append(models.Q(archived_at__isnull=True))
match query.mode:
case AssociationsSearchMode.STARRED:
result.append(models.Q(starred_at__isnull=False))
if query.before is not None:
result.append(models.Q(pk__lt=query.before))
if query.after is not None:
result.append(models.Q(pk__gte=query.after))
if query.search is not None:
result.extend(self.get_search_term_filters(query=query))
return result

View File

@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from .base import AssociationAdapter, SaveAdapter
class BasicSaveAdapter(SaveAdapter):
pass
class BasicAssociationAdapter(AssociationAdapter):
pass

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
import uuid
import django.db
from django.db import models
from hotpocket_backend.apps.saves.models import Save
from hotpocket_common.db import postgres # noqa: F401
from hotpocket_soa.dto.associations import AssociationsQuery
from .base import AssociationAdapter, SaveAdapter
LOGGER = logging.getLogger(__name__)
class PostgresSaveAdapter(SaveAdapter):
ROW_LOCKED_MESSAGE = 'could not obtain lock on row in relation "saves_save"'
def get_for_processing(self, *, pk: uuid.UUID) -> Save | None:
try:
return Save.active_objects.select_for_update(nowait=True).get(pk=pk)
except django.db.utils.OperationalError as exception:
if exception.args[0].startswith(self.ROW_LOCKED_MESSAGE) is True:
LOGGER.info('Trying to process a locked save: pk=`%s`', pk)
return None
raise exception
class PostgresAssociationAdapter(AssociationAdapter):
def get_search_term_filters(self, *, query: AssociationsQuery) -> list[models.Q]:
result = [
(
models.Q(target__url__ilike=query.search)
|
models.Q(target_title__ilike=query.search)
|
models.Q(target__title__ilike=query.search)
|
models.Q(target_description__ilike=query.search)
|
models.Q(target__description__ilike=query.search)
),
]
return result

View File

@@ -0,0 +1 @@
from . import save # noqa: F401

View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from hotpocket_backend.apps.saves.models import Save
class SaveAdmin(admin.ModelAdmin):
list_display = (
'pk', 'key', 'account_uuid', 'created_at', 'render_is_active',
)
ordering = ['-created_at']
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
@admin.display(
description=_('Is Active?'), boolean=True, ordering='-deleted_at',
)
def render_is_active(self, obj: Save | None = None) -> bool | None:
if obj is None:
return None
return obj.is_active
admin.site.register(Save, SaveAdmin)

View File

@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
label = 'saves'
name = 'hotpocket_backend.apps.saves'
verbose_name = _('Saves')

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from argparse import ArgumentParser
import logging
from django.core.management import BaseCommand
import django.db
from hotpocket_backend.apps.saves.models import Association
from hotpocket_backend.apps.saves.services import AssociationsService
from hotpocket_soa.dto.associations import AssociationUpdateIn
LOGGER = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Data migration for BTHLABS-38'
def add_arguments(self, parser: ArgumentParser):
parser.add_argument(
'-d', '--dry-run', action='store_true', default=False,
help='Dry run',
)
def handle(self, *args, **options):
dry_run = options.get('dry_run', False)
counters = {
'migrated': 0,
'errors': 0,
}
associations_service = AssociationsService()
with django.db.transaction.atomic():
for association in Association.objects.all():
LOGGER.info('Migrating: `%s`', association)
try:
update = AssociationUpdateIn.model_validate(dict(
target_title=(
association.target_title
if association.target_title is not None
else association.target_meta.get('title', None)
),
target_description=(
association.target_description
if association.target_description is not None
else association.target_meta.get('description', None)
),
))
_ = associations_service.update(
pk=association.pk,
update=update,
)
counters['migrated'] = counters['migrated'] + 1
except Exception as exception:
LOGGER.error(
'Unhandled exception: pk=`%s`: %s',
association.pk,
exception,
exc_info=exception,
)
counters['errors'] = counters['errors'] + 1
LOGGER.info(
'Done. Migrated: `%d`. Errors: `%d`',
counters['migrated'],
counters['errors'],
)
if dry_run is True:
raise RuntimeError('DRY RUN')

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.3 on 2025-07-02 11:15
import uuid6
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Save',
fields=[
('id', models.UUIDField(default=uuid6.uuid7, editable=False, primary_key=True, serialize=False)),
('account_uuid', models.UUIDField(db_index=True, default=None)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('deleted_at', models.DateTimeField(blank=True, db_index=True, default=None, null=True)),
('key', models.CharField(db_index=True, default=None)),
('url', models.CharField(default=None)),
],
options={
'verbose_name': 'Save',
'verbose_name_plural': 'Saves',
},
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.2.3 on 2025-07-03 08:33
import django.db.models.deletion
import uuid6
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('saves', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='save',
name='deleted_at',
field=models.DateTimeField(blank=True, db_index=True, default=None, editable=False, null=True),
),
migrations.CreateModel(
name='Association',
fields=[
('id', models.UUIDField(default=uuid6.uuid7, editable=False, primary_key=True, serialize=False)),
('account_uuid', models.UUIDField(db_index=True, default=None)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('deleted_at', models.DateTimeField(blank=True, db_index=True, default=None, editable=False, null=True)),
('archived_at', models.DateTimeField(blank=True, db_index=True, default=None, editable=False, null=True)),
('target', models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='saves.save')),
],
options={
'verbose_name': 'Association',
'verbose_name_plural': 'Associations',
},
),
]

View File

@@ -0,0 +1,39 @@
# Generated by Django 5.2.3 on 2025-07-03 20:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('saves', '0002_alter_save_deleted_at_association'),
]
operations = [
migrations.AddField(
model_name='save',
name='content',
field=models.BinaryField(blank=True, default=None, null=True),
),
migrations.AddField(
model_name='save',
name='description',
field=models.CharField(blank=True, default=None, null=True),
),
migrations.AddField(
model_name='save',
name='last_processed_at',
field=models.DateTimeField(blank=True, default=None, null=True),
),
migrations.AddField(
model_name='save',
name='title',
field=models.CharField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name='association',
name='target',
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='associations', to='saves.save'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-07-14 06:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('saves', '0003_save_content_save_description_save_last_processed_at_and_more'),
]
operations = [
migrations.AddField(
model_name='association',
name='starred_at',
field=models.DateTimeField(blank=True, db_index=True, default=None, editable=False, null=True),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.3 on 2025-07-17 18:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('saves', '0004_association_starred_at'),
]
operations = [
migrations.AddField(
model_name='association',
name='target_meta',
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name='save',
name='is_netloc_banned',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-07-19 09:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('saves', '0005_association_target_meta_save_is_netloc_banned'),
]
operations = [
migrations.AlterField(
model_name='association',
name='target_meta',
field=models.JSONField(blank=True, default=dict),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.3 on 2025-07-26 12:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('saves', '0006_alter_association_target_meta'),
]
operations = [
migrations.AddField(
model_name='association',
name='target_description',
field=models.CharField(blank=True, db_index=True, default=None, null=True),
),
migrations.AddField(
model_name='association',
name='target_title',
field=models.CharField(blank=True, db_index=True, default=None, null=True),
),
]

View File

@@ -0,0 +1,2 @@
from .association import Association # noqa: F401
from .save import Save # noqa: F401

View File

@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.db import models
from django.utils.translation import gettext_lazy as _
from hotpocket_backend.apps.core.models import Model
class ActiveAssociationsManager(models.Manager):
def get_queryset(self) -> models.QuerySet[Association]:
return super().get_queryset().filter(
deleted_at__isnull=True,
)
class Association(Model):
archived_at = models.DateTimeField(
auto_now=False,
auto_now_add=False,
blank=True,
null=True,
default=None,
db_index=True,
editable=False,
)
starred_at = models.DateTimeField(
auto_now=False,
auto_now_add=False,
blank=True,
null=True,
default=None,
db_index=True,
editable=False,
)
target_meta = models.JSONField(
blank=True,
null=False,
default=dict,
)
target_title = models.CharField(
blank=True,
null=True,
default=None,
db_index=True,
)
target_description = models.CharField(
blank=True,
null=True,
default=None,
db_index=True,
)
target = models.ForeignKey(
'saves.Save',
blank=False,
null=False,
default=None,
db_index=True,
related_name='associations',
on_delete=models.CASCADE,
)
objects = models.Manager()
active_objects = ActiveAssociationsManager()
class Meta:
verbose_name = _('Association')
verbose_name_plural = _('Associations')
def __str__(self) -> str:
return f'<Association pk={self.pk}>'

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.db import models
from django.utils.translation import gettext_lazy as _
from hotpocket_backend.apps.core.models import Model
class ActiveSavesManager(models.Manager):
def get_queryset(self) -> models.QuerySet[Save]:
return super().get_queryset().filter(
deleted_at__isnull=True,
)
class Save(Model):
key = models.CharField(
blank=False, null=False, default=None, db_index=True,
)
url = models.CharField(
blank=False, null=False, default=None,
)
content = models.BinaryField(
blank=True, null=True, default=None, editable=False,
)
title = models.CharField(
blank=True, null=True, default=None,
)
description = models.CharField(
blank=True, null=True, default=None,
)
last_processed_at = models.DateTimeField(
auto_now=False,
auto_now_add=False,
blank=True,
null=True,
default=None,
)
is_netloc_banned = models.BooleanField(
blank=False, null=False, default=False,
)
objects = models.Manager()
active_objects = ActiveSavesManager()
class Meta:
verbose_name = _('Save')
verbose_name_plural = _('Saves')
def __str__(self) -> str:
return f'<Save pk={self.pk} key={self.key}>'

View File

@@ -0,0 +1,3 @@
from .associations import AssociationsService # noqa: F401
from .save_processor import SaveProcessorService # noqa: F401
from .saves import SavesService # noqa: F401

View File

@@ -0,0 +1,160 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import datetime
import logging
import uuid
from django.db import models
from django.utils.timezone import now
from hotpocket_backend.apps.core.services import get_adapter
from hotpocket_backend.apps.saves.models import Association, Save
from hotpocket_backend.apps.saves.types import PAssociationAdapter
from hotpocket_soa.dto.associations import (
AssociationsQuery,
AssociationUpdateIn,
)
from .saves import SavesService
LOGGER = logging.getLogger(__name__)
class AssociationsService:
class AssociationsServiceError(Exception):
pass
class AssociationNotFound(AssociationsServiceError):
pass
@property
def adapter(self) -> PAssociationAdapter:
if hasattr(self, '_adapter') is False:
adapter_klass = get_adapter(
'SAVES_ASSOCIATION_ADAPTER',
'hotpocket_backend.apps.saves.adapters.basic:BasicAssociationAdapter',
)
self._adapter = adapter_klass()
return self._adapter
def create(self,
*,
account_uuid: uuid.UUID,
save_uuid: uuid.UUID,
pk: uuid.UUID | None = None,
created_at: datetime.datetime | None = None,
) -> Association:
save = SavesService().get(pk=save_uuid)
defaults = dict(
account_uuid=account_uuid,
target=save,
)
if pk is not None:
defaults['id'] = pk
result, created = Association.objects.get_or_create(
account_uuid=account_uuid,
deleted_at__isnull=True,
target=save,
archived_at__isnull=True,
defaults=defaults,
)
if created is True:
if created_at is not None:
result.created_at = created_at
result.save()
return result
def get(self,
*,
pk: uuid.UUID,
with_target: bool = False,
) -> Association:
try:
query_set = Association.active_objects.\
filter(
target__deleted_at__isnull=True,
)
if with_target is True:
query_set = query_set.select_related('target')
return query_set.get(pk=pk)
except Association.DoesNotExist as exception:
raise self.AssociationNotFound(
f'Association not found: pk=`{pk}`',
) from exception
def search(self,
*,
query: AssociationsQuery,
offset: int = 0,
limit: int = 10,
order_by: str = '-pk',
) -> models.QuerySet[Save]:
filters = self.adapter.get_search_filters(query=query)
result = Association.active_objects.\
select_related('target').\
filter(*filters).\
order_by(order_by)
return result[offset:offset + limit]
def update(self,
*,
pk: uuid.UUID,
update: AssociationUpdateIn,
) -> Association:
association = self.get(pk=pk)
association.target_title = update.target_title
association.target_description = update.target_description
next_target_meta = {
**(association.target_meta or {}),
}
next_target_meta.pop('title', None)
next_target_meta.pop('description', None)
association.target_meta = next_target_meta
association.save()
return association
def archive(self, *, pk: uuid.UUID) -> bool:
association = self.get(pk=pk)
association.archived_at = now()
association.save()
return True
def star(self, *, pk: uuid.UUID) -> Association:
association = self.get(pk=pk)
if association.starred_at is None:
association.starred_at = now()
association.save()
return association
def unstar(self, *, pk: uuid.UUID) -> Association:
association = self.get(pk=pk)
if association.starred_at is not None:
association.starred_at = None
association.save()
return association
def delete(self, *, pk: uuid.UUID) -> bool:
association = self.get(pk=pk)
association.soft_delete()
return True

View File

@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
import uuid
from celery.result import AsyncResult
from django.utils.timezone import now
from hotpocket_backend.apps.bot.services import BotService
from hotpocket_backend.apps.saves.tasks import process_save
from .saves import SavesService
LOGGER = logging.getLogger(__name__)
class SaveProcessorService:
class SaveProcessorServiceError(Exception):
pass
class SkipReprocessing(SaveProcessorServiceError):
pass
def process(self, *, pk: uuid.UUID) -> bool:
result = True
try:
save = SavesService().get_for_processing(pk=pk)
assert save is not None, ('Could not fetch the save.')
title: str = save.url
description: str | None = None
is_netloc_banned = False
try:
bot_result = BotService().handle(url=save.url)
if bot_result.title is not None:
title = bot_result.title
description = bot_result.description
is_netloc_banned = bot_result.is_netloc_banned
except Exception as exception:
LOGGER.error(
'Unhandled exception when fetching save content: %s',
exception,
exc_info=exception,
)
if save.last_processed_at is not None:
raise self.SkipReprocessing(
'Not re-processing the save fetch error!',
) from exception
save.title = title
save.description = description
save.is_netloc_banned = is_netloc_banned
save.last_processed_at = now()
save.save()
except Exception as exception:
LOGGER.error(
'Unhandled exception: pk=`%s`: %s',
pk,
exception,
exc_info=exception,
)
result = False
return result
def schedule_process_save(self, *, pk: uuid.UUID) -> AsyncResult:
return process_save.apply_async(
kwargs={
'pk': pk,
},
)

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import hashlib
import typing
import uuid
from django.db import models
from hotpocket_backend.apps.core.services import get_adapter
from hotpocket_backend.apps.saves.models import Save
from hotpocket_backend.apps.saves.types import PSaveAdapter
from hotpocket_soa.dto.saves import ImportedSaveIn, SaveIn, SavesQuery
class SavesService:
class SavesServiceError(Exception):
pass
class SaveNotFound(SavesServiceError):
pass
@property
def adapter(self) -> PSaveAdapter:
if hasattr(self, '_adapter') is False:
adapter_klass = get_adapter(
'SAVES_SAVE_ADAPTER',
'hotpocket_backend.apps.saves.adapters.basic:BasicSaveAdapter',
)
self._adapter = adapter_klass()
return self._adapter
def create(self,
*,
account_uuid: uuid.UUID,
save: SaveIn | ImportedSaveIn,
) -> Save:
key = hashlib.sha256(save.url.encode('utf-8')).hexdigest()
defaults = dict(
account_uuid=account_uuid,
key=key,
url=save.url,
)
save_object, created = Save.objects.get_or_create(
key=key,
deleted_at__isnull=True,
defaults=defaults,
)
if created is True:
save_object.is_netloc_banned = save.is_netloc_banned
if isinstance(save, ImportedSaveIn) is True:
save_object.title = save.title # type: ignore[union-attr]
save_object.save()
return save_object
def get(self, *, pk: uuid.UUID) -> Save:
try:
return Save.active_objects.get(pk=pk)
except Save.DoesNotExist as exception:
raise self.SaveNotFound(
f'Save not found: pk=`{pk}`',
) from exception
def search(self,
*,
filters: typing.Any,
offset: int = 0,
limit: int = 10,
order: str = '-created_at',
) -> models.QuerySet[Save]:
raise NotImplementedError('TODO')
def update(self, *, pk: uuid.UUID, save: SaveIn) -> Save:
raise NotImplementedError('TODO')
def delete(self, *, pk: uuid.UUID) -> bool:
raise NotImplementedError('TODO')
def search_associated_to_account(self,
*,
account_uuid: uuid.UUID,
query: SavesQuery,
offset: int = 0,
limit: int = 10,
order_by: str = '-associations__created_at',
) -> models.QuerySet[Save]:
result = Save.active_objects.\
filter(
associations__account_uuid=account_uuid,
associations__archived_at__isnull=True,
).\
order_by(order_by)
return result
def get_for_processing(self, *, pk: uuid.UUID) -> Save:
return self.adapter.get_for_processing(pk=pk)

View File

@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import uuid
from celery import shared_task
from django import db
@shared_task
def process_save(*, pk: uuid.UUID):
from hotpocket_backend.apps.saves.services import SaveProcessorService
with db.transaction.atomic():
return SaveProcessorService().process(pk=pk)

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
import uuid
from django.db import models
from hotpocket_backend.apps.saves.models import Save
from hotpocket_soa.dto.associations import AssociationsQuery
class PSaveAdapter(typing.Protocol):
def get_for_processing(self, *, pk: uuid.UUID) -> Save:
...
class PAssociationAdapter(typing.Protocol):
def get_search_filters(self, *, query: AssociationsQuery) -> list[models.Q]:
...

View File

@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class UIConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
label = 'ui'
name = 'hotpocket_backend.apps.ui'
verbose_name = _('UI')

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import enum
class MessageLevelAlertClass(enum.Enum):
debug = 'alert-secondary'
info = 'alert-info'
success = 'alert-success'
warning = 'alert-warning'
error = 'alert-danger'
class StarUnstarAssociationViewMode(enum.Enum):
STAR = 'STAR'
UNSTAR = 'UNSTAR'

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.http import HttpRequest
from hotpocket_backend._meta import version as backend_version
from hotpocket_backend.apps.core.conf import settings
from hotpocket_backend.apps.core.context import get_request_id
def site_title(request: HttpRequest) -> dict:
return {
'SITE_TITLE': settings.SITE_TITLE,
}
def image_tag(request: HttpRequest) -> dict:
return {
'IMAGE_ID': settings.IMAGE_ID,
}
def request_id(request: HttpRequest) -> dict:
return {
'REQUEST_ID': get_request_id(),
}
def htmx(request: HttpRequest) -> dict:
return {
'HTMX': (
request.htmx
if hasattr(request, 'htmx')
else False
),
}
def debug(request: HttpRequest) -> dict:
return {
'DEBUG': settings.DEBUG,
}
def version(request: HttpRequest) -> dict:
return {
'VERSION': backend_version,
}

Some files were not shown because too many files have changed in this diff Show More