You've already forked hotpocket
This commit is contained in:
8
services/backend/hotpocket_backend/__init__.py
Normal file
8
services/backend/hotpocket_backend/__init__.py
Normal 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
|
||||
4
services/backend/hotpocket_backend/_meta.py
Normal file
4
services/backend/hotpocket_backend/_meta.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
version = '1.0.0'
|
||||
0
services/backend/hotpocket_backend/apps/__init__.py
Normal file
0
services/backend/hotpocket_backend/apps/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .account import AccountAdmin # noqa: F401
|
||||
@@ -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)
|
||||
12
services/backend/hotpocket_backend/apps/accounts/apps.py
Normal file
12
services/backend/hotpocket_backend/apps/accounts/apps.py
Normal 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')
|
||||
11
services/backend/hotpocket_backend/apps/accounts/checks.py
Normal file
11
services/backend/hotpocket_backend/apps/accounts/checks.py
Normal 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,
|
||||
))
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
14
services/backend/hotpocket_backend/apps/accounts/mixins.py
Normal file
14
services/backend/hotpocket_backend/apps/accounts/mixins.py
Normal 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)
|
||||
@@ -0,0 +1 @@
|
||||
from .account import Account # noqa: F401
|
||||
@@ -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
|
||||
40
services/backend/hotpocket_backend/apps/accounts/social.py
Normal file
40
services/backend/hotpocket_backend/apps/accounts/social.py
Normal 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)
|
||||
18
services/backend/hotpocket_backend/apps/accounts/types.py
Normal file
18
services/backend/hotpocket_backend/apps/accounts/types.py
Normal 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
|
||||
17
services/backend/hotpocket_backend/apps/admin.py
Normal file
17
services/backend/hotpocket_backend/apps/admin.py
Normal 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'
|
||||
22
services/backend/hotpocket_backend/apps/bot/apps.py
Normal file
22
services/backend/hotpocket_backend/apps/bot/apps.py
Normal 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
|
||||
54
services/backend/hotpocket_backend/apps/bot/conf.py
Normal file
54
services/backend/hotpocket_backend/apps/bot/conf.py
Normal 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]
|
||||
11
services/backend/hotpocket_backend/apps/bot/dto/strategy.py
Normal file
11
services/backend/hotpocket_backend/apps/bot/dto/strategy.py
Normal 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
|
||||
@@ -0,0 +1 @@
|
||||
from .bot import BotService # noqa: F401
|
||||
16
services/backend/hotpocket_backend/apps/bot/services/bot.py
Normal file
16
services/backend/hotpocket_backend/apps/bot/services/bot.py
Normal 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()
|
||||
@@ -0,0 +1 @@
|
||||
from .basic import BasicStrategy # noqa: F401
|
||||
174
services/backend/hotpocket_backend/apps/bot/strategy/base.py
Normal file
174
services/backend/hotpocket_backend/apps/bot/strategy/base.py
Normal 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
|
||||
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import Strategy
|
||||
|
||||
|
||||
class BasicStrategy(Strategy):
|
||||
pass
|
||||
17
services/backend/hotpocket_backend/apps/bot/types.py
Normal file
17
services/backend/hotpocket_backend/apps/bot/types.py
Normal 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:
|
||||
...
|
||||
12
services/backend/hotpocket_backend/apps/core/apps.py
Normal file
12
services/backend/hotpocket_backend/apps/core/apps.py
Normal 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')
|
||||
8
services/backend/hotpocket_backend/apps/core/conf.py
Normal file
8
services/backend/hotpocket_backend/apps/core/conf.py
Normal 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
|
||||
14
services/backend/hotpocket_backend/apps/core/context.py
Normal file
14
services/backend/hotpocket_backend/apps/core/context.py
Normal 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()
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
from .request_id import RequestIDMiddleware # noqa: F401
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
from .base import Model # noqa: F401
|
||||
54
services/backend/hotpocket_backend/apps/core/models/base.py
Normal file
54
services/backend/hotpocket_backend/apps/core/models/base.py
Normal 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()
|
||||
13
services/backend/hotpocket_backend/apps/core/services.py
Normal file
13
services/backend/hotpocket_backend/apps/core/services.py
Normal 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)
|
||||
22
services/backend/hotpocket_backend/apps/core/tasks.py
Normal file
22
services/backend/hotpocket_backend/apps/core/tasks.py
Normal 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,
|
||||
)
|
||||
@@ -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 %}
|
||||
32
services/backend/hotpocket_backend/apps/core/types.py
Normal file
32
services/backend/hotpocket_backend/apps/core/types.py
Normal 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
|
||||
12
services/backend/hotpocket_backend/apps/htmx/apps.py
Normal file
12
services/backend/hotpocket_backend/apps/htmx/apps.py
Normal 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')
|
||||
40
services/backend/hotpocket_backend/apps/htmx/messages.py
Normal file
40
services/backend/hotpocket_backend/apps/htmx/messages.py
Normal 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',
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import AssociationAdapter, SaveAdapter
|
||||
|
||||
|
||||
class BasicSaveAdapter(SaveAdapter):
|
||||
pass
|
||||
|
||||
|
||||
class BasicAssociationAdapter(AssociationAdapter):
|
||||
pass
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
from . import save # noqa: F401
|
||||
29
services/backend/hotpocket_backend/apps/saves/admin/save.py
Normal file
29
services/backend/hotpocket_backend/apps/saves/admin/save.py
Normal 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)
|
||||
12
services/backend/hotpocket_backend/apps/saves/apps.py
Normal file
12
services/backend/hotpocket_backend/apps/saves/apps.py
Normal 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')
|
||||
@@ -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')
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
from .association import Association # noqa: F401
|
||||
from .save import Save # noqa: F401
|
||||
@@ -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}>'
|
||||
52
services/backend/hotpocket_backend/apps/saves/models/save.py
Normal file
52
services/backend/hotpocket_backend/apps/saves/models/save.py
Normal 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}>'
|
||||
@@ -0,0 +1,3 @@
|
||||
from .associations import AssociationsService # noqa: F401
|
||||
from .save_processor import SaveProcessorService # noqa: F401
|
||||
from .saves import SavesService # noqa: F401
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
104
services/backend/hotpocket_backend/apps/saves/services/saves.py
Normal file
104
services/backend/hotpocket_backend/apps/saves/services/saves.py
Normal 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)
|
||||
15
services/backend/hotpocket_backend/apps/saves/tasks.py
Normal file
15
services/backend/hotpocket_backend/apps/saves/tasks.py
Normal 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)
|
||||
20
services/backend/hotpocket_backend/apps/saves/types.py
Normal file
20
services/backend/hotpocket_backend/apps/saves/types.py
Normal 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]:
|
||||
...
|
||||
12
services/backend/hotpocket_backend/apps/ui/apps.py
Normal file
12
services/backend/hotpocket_backend/apps/ui/apps.py
Normal 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')
|
||||
17
services/backend/hotpocket_backend/apps/ui/constants.py
Normal file
17
services/backend/hotpocket_backend/apps/ui/constants.py
Normal 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'
|
||||
@@ -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,
|
||||
}
|
||||
28
services/backend/hotpocket_backend/apps/ui/dto/base.py
Normal file
28
services/backend/hotpocket_backend/apps/ui/dto/base.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
from django.http import HttpRequest
|
||||
import pydantic
|
||||
|
||||
|
||||
class BrowseParams(pydantic.BaseModel):
|
||||
view_name: str
|
||||
account_uuid: uuid.UUID
|
||||
search: str | None = pydantic.Field(default=None)
|
||||
before: uuid.UUID | None = pydantic.Field(default=None)
|
||||
after: uuid.UUID | None = pydantic.Field(default=None)
|
||||
limit: int = pydantic.Field(default=10)
|
||||
|
||||
@classmethod
|
||||
def from_request(cls: type[typing.Self],
|
||||
*,
|
||||
request: HttpRequest,
|
||||
) -> typing.Self:
|
||||
return cls.model_validate({
|
||||
'view_name': request.resolver_match.url_name,
|
||||
'account_uuid': request.user.pk,
|
||||
**request.GET.dict(),
|
||||
})
|
||||
13
services/backend/hotpocket_backend/apps/ui/dto/saves.py
Normal file
13
services/backend/hotpocket_backend/apps/ui/dto/saves.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import pydantic
|
||||
|
||||
from hotpocket_common.constants import AssociationsSearchMode
|
||||
|
||||
from .base import BrowseParams as BaseBrowseParams
|
||||
|
||||
|
||||
class BrowseParams(BaseBrowseParams):
|
||||
limit: int = pydantic.Field(default=12)
|
||||
mode: AssociationsSearchMode = pydantic.Field(default=AssociationsSearchMode.DEFAULT)
|
||||
215
services/backend/hotpocket_backend/apps/ui/forms/accounts.py
Normal file
215
services/backend/hotpocket_backend/apps/ui/forms/accounts.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Submit
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import (
|
||||
AuthenticationForm as BaseAuthenticationForm,
|
||||
PasswordChangeForm as BasePasswordChangeForm,
|
||||
)
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base import Form
|
||||
|
||||
|
||||
class LoginForm(BaseAuthenticationForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper(self)
|
||||
self.helper.attrs = {
|
||||
'id': self.__class__.__name__,
|
||||
'novalidate': '',
|
||||
}
|
||||
self.helper.layout = Layout(
|
||||
'username',
|
||||
'password',
|
||||
FormActions(
|
||||
Submit('submit', _('Log in'), css_class='btn btn-primary'),
|
||||
template='ui/ui/forms/formactions.html',
|
||||
css_class='mb-0',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ProfileForm(Form):
|
||||
INCLUDE_CANCEL = False
|
||||
|
||||
username = forms.CharField(
|
||||
label=_('Username'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
first_name = forms.CharField(
|
||||
label=_('First name'),
|
||||
max_length=150,
|
||||
required=True,
|
||||
)
|
||||
last_name = forms.CharField(
|
||||
label=_('Last name'),
|
||||
max_length=150,
|
||||
required=True,
|
||||
)
|
||||
email = forms.EmailField(
|
||||
label=_('E-mail address'),
|
||||
required=True,
|
||||
)
|
||||
|
||||
def get_layout_fields(self) -> list[str]:
|
||||
return [
|
||||
'username',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
]
|
||||
|
||||
|
||||
class FederatedProfileForm(ProfileForm):
|
||||
username = forms.CharField(
|
||||
label=_('Username'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
first_name = forms.CharField(
|
||||
label=_('First name'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
last_name = forms.CharField(
|
||||
label=_('Last name'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
email = forms.EmailField(
|
||||
label=_('E-mail address'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit(
|
||||
'submit',
|
||||
_('Save'),
|
||||
css_class='btn btn-primary',
|
||||
disabled=True,
|
||||
)
|
||||
|
||||
def clean(self) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
class PasswordForm(BasePasswordChangeForm):
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit(
|
||||
'submit',
|
||||
_('Save'),
|
||||
css_class='btn btn-primary',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper(self)
|
||||
self.helper.form_class = 'form-horizontal'
|
||||
self.helper.label_class = 'col-md-3 col-form-label'
|
||||
self.helper.field_class = 'col-md-9'
|
||||
self.helper.attrs = {
|
||||
'id': self.__class__.__name__,
|
||||
'novalidate': '',
|
||||
}
|
||||
self.helper.layout = Layout(
|
||||
'old_password',
|
||||
'new_password1',
|
||||
'new_password2',
|
||||
FormActions(
|
||||
self.get_submit_button(),
|
||||
template='ui/ui/forms/formactions-horizontal.html',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class FederatedPasswordForm(PasswordForm):
|
||||
current_password = forms.CharField(
|
||||
label=_('Old password'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
new_password = forms.CharField(
|
||||
label=_('New password'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
new_password_again = forms.CharField(
|
||||
label=_('New password confirmation'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit(
|
||||
'submit',
|
||||
_('Save'),
|
||||
css_class='btn btn-primary disable',
|
||||
disabled=True,
|
||||
)
|
||||
|
||||
def clean(self) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
class SettingsForm(Form):
|
||||
INCLUDE_CANCEL = False
|
||||
|
||||
theme = forms.ChoiceField(
|
||||
label=_('Theme'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
choices=[
|
||||
(None, _('Bootstrap')),
|
||||
('cosmo', _('Cosmo')),
|
||||
],
|
||||
show_hidden_initial=True,
|
||||
)
|
||||
auto_load_embeds = forms.ChoiceField(
|
||||
label=_('Auto load embedded content'),
|
||||
required=False,
|
||||
choices=[
|
||||
(None, _('---')),
|
||||
(True, _('Yes')),
|
||||
(False, _('No')),
|
||||
],
|
||||
help_text=_((
|
||||
'Auto loading embedded content (e.g. YouTube videos) means that '
|
||||
'you allow cookies from these sites.'
|
||||
)),
|
||||
)
|
||||
|
||||
def get_layout_fields(self) -> list[str]:
|
||||
return [
|
||||
'theme',
|
||||
'auto_load_embeds',
|
||||
]
|
||||
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit(
|
||||
'submit',
|
||||
_('Save'),
|
||||
css_class='btn btn-primary',
|
||||
)
|
||||
|
||||
def clean(self) -> dict:
|
||||
result = super().clean()
|
||||
|
||||
theme = result.get('theme', None)
|
||||
if not theme:
|
||||
result['theme'] = None
|
||||
|
||||
auto_load_embeds = result.get('auto_load_embeds', None)
|
||||
if not auto_load_embeds:
|
||||
result['auto_load_embeds'] = None
|
||||
else:
|
||||
result['auto_load_embeds'] = (auto_load_embeds == 'True')
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,95 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from crispy_forms.layout import Submit
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base import Form
|
||||
|
||||
|
||||
class AssociationForm(Form):
|
||||
pass
|
||||
|
||||
|
||||
class ConfirmationForm(AssociationForm):
|
||||
canhazconfirm = forms.CharField(
|
||||
label='',
|
||||
required=True,
|
||||
widget=forms.HiddenInput,
|
||||
)
|
||||
title = forms.CharField(
|
||||
label=_('Title'),
|
||||
required=False,
|
||||
disabled=True,
|
||||
show_hidden_initial=True,
|
||||
)
|
||||
url = forms.CharField(
|
||||
label=_('URL'),
|
||||
required=False,
|
||||
disabled=True,
|
||||
show_hidden_initial=True,
|
||||
)
|
||||
|
||||
def get_layout_fields(self) -> list[str]:
|
||||
return [
|
||||
'canhazconfirm',
|
||||
'title',
|
||||
'url',
|
||||
]
|
||||
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit('submit', _('Confirm'), css_class='btn btn-primary')
|
||||
|
||||
|
||||
class EditForm(AssociationForm):
|
||||
url = forms.CharField(
|
||||
label=_('URL'),
|
||||
required=False,
|
||||
disabled=True,
|
||||
show_hidden_initial=True,
|
||||
)
|
||||
target_title = forms.CharField(
|
||||
label=_('Title'),
|
||||
required=False,
|
||||
)
|
||||
target_description = forms.CharField(
|
||||
label=_('Description'),
|
||||
required=False,
|
||||
widget=forms.Textarea,
|
||||
)
|
||||
|
||||
def get_layout_fields(self) -> list[str]:
|
||||
return [
|
||||
'url',
|
||||
'target_title',
|
||||
'target_description',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
result = {}
|
||||
|
||||
if cleaned_data['target_title']:
|
||||
result['target_title'] = cleaned_data['target_title']
|
||||
|
||||
if cleaned_data['target_description']:
|
||||
result['target_description'] = cleaned_data['target_description']
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class RefreshForm(ConfirmationForm):
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit('submit', _('Refresh'), css_class='btn btn-warning')
|
||||
|
||||
|
||||
class ArchiveForm(ConfirmationForm):
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit('submit', _('Archive'), css_class='btn btn-danger')
|
||||
|
||||
|
||||
class DeleteForm(ConfirmationForm):
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit('submit', _('Delete'), css_class='btn btn-danger')
|
||||
63
services/backend/hotpocket_backend/apps/ui/forms/base.py
Normal file
63
services/backend/hotpocket_backend/apps/ui/forms/base.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Submit
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .layout import CancelButton
|
||||
|
||||
|
||||
class Form(forms.Form):
|
||||
HORIZONTAL = True
|
||||
INCLUDE_CANCEL = True
|
||||
|
||||
def get_layout_fields(self) -> list[str]:
|
||||
return []
|
||||
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit('submit', _('Save'), css_class='btn btn-primary')
|
||||
|
||||
def get_extra_actions(self) -> typing.Any:
|
||||
result = []
|
||||
|
||||
if self.INCLUDE_CANCEL is True:
|
||||
result.append(CancelButton(_('Cancel')))
|
||||
|
||||
return result
|
||||
|
||||
def get_form_actions_template(self) -> str:
|
||||
if self.HORIZONTAL is True:
|
||||
return 'ui/ui/forms/formactions-horizontal.html'
|
||||
|
||||
return 'ui/ui/forms/formactions.html'
|
||||
|
||||
def get_form_helper_form_class(self) -> str:
|
||||
if self.HORIZONTAL is True:
|
||||
return 'form-horizontal'
|
||||
|
||||
return ''
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper(self)
|
||||
self.helper.form_class = self.get_form_helper_form_class()
|
||||
self.helper.label_class = 'col-md-3 col-form-label'
|
||||
self.helper.field_class = 'col-md-9'
|
||||
self.helper.attrs = {
|
||||
'id': self.__class__.__name__,
|
||||
'novalidate': '',
|
||||
}
|
||||
self.helper.layout = Layout(
|
||||
*self.get_layout_fields(),
|
||||
FormActions(
|
||||
self.get_submit_button(),
|
||||
*self.get_extra_actions(),
|
||||
template=self.get_form_actions_template(),
|
||||
),
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user