BTHLABS-50: Safari Web extension
Co-authored-by: Tomek Wójcik <labs@tomekwojcik.pl> Co-committed-by: Tomek Wójcik <labs@tomekwojcik.pl>
@@ -64,4 +64,10 @@ export default defineConfig([
|
||||
'no-invalid-this': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'hotpocket_backend/apps/**/static/**/*.js',
|
||||
'hotpocket_backend/static/**/*.js',
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from .access_token import AccessToken # noqa: F401
|
||||
from .account import AccountAdmin # noqa: F401
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from hotpocket_backend.apps.accounts.models import AccessToken
|
||||
|
||||
|
||||
class AccessTokenAdmin(admin.ModelAdmin):
|
||||
list_display = ('pk', 'account_uuid', 'origin', 'created_at', 'is_active')
|
||||
search_fields = ('pk', 'account_uuid', 'key', 'origin')
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return request.user.is_superuser
|
||||
|
||||
|
||||
admin.site.register(AccessToken, AccessTokenAdmin)
|
||||
36
services/backend/hotpocket_backend/apps/accounts/backends.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.contrib.auth.backends import ModelBackend, UserModel
|
||||
from django.http import HttpRequest
|
||||
|
||||
from hotpocket_backend.apps.accounts.models import AccessToken, Account
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccessTokenBackend(ModelBackend):
|
||||
def authenticate(self,
|
||||
request: HttpRequest,
|
||||
access_token: AccessToken | None,
|
||||
) -> Account | None:
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
try:
|
||||
user = UserModel.objects.get(pk=access_token.account_uuid)
|
||||
except UserModel.DoesNotExist as exception:
|
||||
LOGGER.error(
|
||||
'Unhandled exception in AccessToken auth: %s',
|
||||
exception,
|
||||
exc_info=exception,
|
||||
)
|
||||
|
||||
if self.user_can_authenticate(user) is False:
|
||||
return None
|
||||
|
||||
request.access_token = access_token
|
||||
return typing.cast(Account, user)
|
||||
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.http import HttpRequest
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
from hotpocket_backend.apps.accounts.models import AccessToken, Account
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccessTokenMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request: HttpRequest):
|
||||
if not hasattr(request, 'user'):
|
||||
raise ImproperlyConfigured('No `AuthenticationMiddleware`?')
|
||||
|
||||
authorization_header = request.headers.get('Authorization', None)
|
||||
if authorization_header is None:
|
||||
return
|
||||
|
||||
try:
|
||||
scheme, authorization = authorization_header.split(' ', maxsplit=1)
|
||||
assert scheme == 'Bearer', (
|
||||
f'Unsupported authorization scheme: `{scheme}`'
|
||||
)
|
||||
|
||||
access_token = AccessToken.active_objects.get(key=authorization)
|
||||
except (ValueError, AssertionError, AccessToken.DoesNotExist, Account.DoesNotExist) as exception:
|
||||
LOGGER.error(
|
||||
'Unhandled exception in AccessToken middleware: %s',
|
||||
exception,
|
||||
exc_info=exception,
|
||||
)
|
||||
return
|
||||
|
||||
account = auth.authenticate(request, access_token=access_token)
|
||||
if account:
|
||||
request.user = account
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.3 on 2025-09-04 18:50
|
||||
|
||||
import uuid6
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0004_alter_account_settings_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AccessToken',
|
||||
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)),
|
||||
('key', models.CharField(db_index=True, default=None, editable=False, max_length=128, unique=True)),
|
||||
('origin', models.CharField(db_index=True, default=None)),
|
||||
('meta', models.JSONField(blank=True, default=dict, null=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Access Token',
|
||||
'verbose_name_plural': 'Access Tokens',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1 +1,2 @@
|
||||
from .access_token import AccessToken # noqa: F401
|
||||
from .account import Account # noqa: F401
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# -*- 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 ActiveAccessTokensManager(models.Manager):
|
||||
def get_queryset(self) -> models.QuerySet[AccessToken]:
|
||||
return super().get_queryset().filter(
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
|
||||
class AccessToken(Model):
|
||||
key = models.CharField(
|
||||
blank=False,
|
||||
default=None,
|
||||
null=False,
|
||||
max_length=128,
|
||||
db_index=True,
|
||||
unique=True,
|
||||
editable=False,
|
||||
)
|
||||
origin = models.CharField(
|
||||
blank=False, default=None, null=False, db_index=True,
|
||||
)
|
||||
meta = models.JSONField(blank=True, default=dict, null=True)
|
||||
|
||||
objects = models.Manager()
|
||||
active_objects = ActiveAccessTokensManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Access Token')
|
||||
verbose_name_plural = _('Access Tokens')
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'<AccessToken pk={self.pk} account_uuid={self.account_uuid}>'
|
||||
@@ -0,0 +1 @@
|
||||
from .access_tokens import AccessTokensService # noqa: F401
|
||||
@@ -0,0 +1,81 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
import uuid6
|
||||
|
||||
from hotpocket_backend.apps.accounts.models import AccessToken
|
||||
from hotpocket_backend.apps.core.conf import settings
|
||||
from hotpocket_soa.dto.accounts import AccessTokensQuery
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccessTokensService:
|
||||
class AccessTokensServiceError(Exception):
|
||||
pass
|
||||
|
||||
class AccessTokenNotFound(AccessTokensServiceError):
|
||||
pass
|
||||
|
||||
def create(self,
|
||||
*,
|
||||
account_uuid: uuid.UUID,
|
||||
origin: str,
|
||||
meta: dict,
|
||||
) -> AccessToken:
|
||||
pk = uuid6.uuid7()
|
||||
key = hmac.new(
|
||||
settings.SECRET_KEY.encode('ascii'),
|
||||
msg=pk.bytes,
|
||||
digestmod=hashlib.sha256,
|
||||
)
|
||||
|
||||
return AccessToken.objects.create(
|
||||
pk=pk,
|
||||
account_uuid=account_uuid,
|
||||
key=key.hexdigest(),
|
||||
origin=origin,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
def get(self, *, pk: uuid.UUID) -> AccessToken:
|
||||
try:
|
||||
query_set = AccessToken.active_objects
|
||||
|
||||
return query_set.get(pk=pk)
|
||||
except AccessToken.DoesNotExist as exception:
|
||||
raise self.AccessTokenNotFound(
|
||||
f'Access Token not found: pk=`{pk}`',
|
||||
) from exception
|
||||
|
||||
def search(self,
|
||||
*,
|
||||
query: AccessTokensQuery,
|
||||
offset: int = 0,
|
||||
limit: int = 10,
|
||||
order_by: str = '-pk',
|
||||
) -> models.QuerySet[AccessToken]:
|
||||
filters = [
|
||||
models.Q(account_uuid=query.account_uuid),
|
||||
]
|
||||
|
||||
if query.before is not None:
|
||||
filters.append(models.Q(pk__lt=query.before))
|
||||
|
||||
result = AccessToken.active_objects.\
|
||||
filter(*filters).\
|
||||
order_by(order_by)
|
||||
|
||||
return result[offset:offset + limit]
|
||||
|
||||
def delete(self, *, pk: uuid.UUID) -> bool:
|
||||
access_token = self.get(pk=pk)
|
||||
access_token.soft_delete()
|
||||
|
||||
return True
|
||||
51
services/backend/hotpocket_backend/apps/core/rpc.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from bthlabs_jsonrpc_django import (
|
||||
DjangoExecutor,
|
||||
DjangoJSONRPCSerializer,
|
||||
JSONRPCView as BaseJSONRPCView,
|
||||
)
|
||||
from django.core.exceptions import ValidationError
|
||||
import uuid6
|
||||
|
||||
|
||||
class JSONRPCSerializer(DjangoJSONRPCSerializer):
|
||||
STRING_COERCIBLE_TYPES: typing.Any = (
|
||||
*DjangoJSONRPCSerializer.STRING_COERCIBLE_TYPES,
|
||||
uuid6.UUID,
|
||||
)
|
||||
|
||||
def serialize_value(self, value: typing.Any) -> typing.Any:
|
||||
if isinstance(value, ValidationError):
|
||||
result: typing.Any = None
|
||||
|
||||
if hasattr(value, 'error_dict') is True:
|
||||
result = {}
|
||||
for field, errors in value.error_dict.items():
|
||||
result[field] = [
|
||||
error.code
|
||||
for error
|
||||
in errors
|
||||
]
|
||||
elif hasattr(value, 'error_list') is True:
|
||||
result = [
|
||||
error.code
|
||||
for error in value.error_list
|
||||
]
|
||||
else:
|
||||
result = value.code
|
||||
|
||||
return self.serialize_value(result)
|
||||
|
||||
return super().serialize_value(value)
|
||||
|
||||
|
||||
class Executor(DjangoExecutor):
|
||||
serializer = JSONRPCSerializer
|
||||
|
||||
|
||||
class JSONRPCView(BaseJSONRPCView):
|
||||
executor = Executor
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class MessageLevelAlertClass(enum.Enum):
|
||||
debug = 'alert-secondary'
|
||||
@@ -15,3 +17,7 @@ class MessageLevelAlertClass(enum.Enum):
|
||||
class StarUnstarAssociationViewMode(enum.Enum):
|
||||
STAR = 'STAR'
|
||||
UNSTAR = 'UNSTAR'
|
||||
|
||||
|
||||
class UIAccessTokenOriginApp(enum.Enum):
|
||||
SAFARI_WEB_EXTENSION = _('Safari Web Extension')
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import BrowseParams as BaseBrowseParams
|
||||
|
||||
|
||||
class AppsBrowseParams(BaseBrowseParams):
|
||||
pass
|
||||
@@ -0,0 +1,46 @@
|
||||
# -*- 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 hotpocket_backend.apps.ui.forms.base import ConfirmationMixin, Form
|
||||
|
||||
|
||||
class AppForm(Form):
|
||||
pass
|
||||
|
||||
|
||||
class ConfirmationForm(ConfirmationMixin, AppForm):
|
||||
origin_app = forms.CharField(
|
||||
label=_('App'),
|
||||
required=False,
|
||||
disabled=True,
|
||||
show_hidden_initial=True,
|
||||
)
|
||||
platform = forms.CharField(
|
||||
label=_('Platform'),
|
||||
required=False,
|
||||
disabled=True,
|
||||
show_hidden_initial=True,
|
||||
)
|
||||
version = forms.CharField(
|
||||
label=_('Version'),
|
||||
required=False,
|
||||
disabled=True,
|
||||
show_hidden_initial=True,
|
||||
)
|
||||
|
||||
def get_layout_fields(self) -> list[str]:
|
||||
return [
|
||||
'canhazconfirm',
|
||||
'origin_app',
|
||||
'platform',
|
||||
'version',
|
||||
]
|
||||
|
||||
|
||||
class DeleteForm(ConfirmationForm):
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit('submit', _('Delete'), css_class='btn btn-danger')
|
||||
@@ -0,0 +1,30 @@
|
||||
# -*- 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.contrib.auth.forms import (
|
||||
AuthenticationForm as BaseAuthenticationForm,
|
||||
)
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
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',
|
||||
),
|
||||
)
|
||||
@@ -6,32 +6,11 @@ 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',
|
||||
),
|
||||
)
|
||||
from hotpocket_backend.apps.ui.forms.base import Form
|
||||
|
||||
|
||||
class ProfileForm(Form):
|
||||
@@ -131,17 +110,17 @@ class PasswordForm(BasePasswordChangeForm):
|
||||
|
||||
|
||||
class FederatedPasswordForm(PasswordForm):
|
||||
current_password = forms.CharField(
|
||||
old_password = forms.CharField(
|
||||
label=_('Old password'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
new_password = forms.CharField(
|
||||
new_password1 = forms.CharField(
|
||||
label=_('New password'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
new_password_again = forms.CharField(
|
||||
new_password2 = forms.CharField(
|
||||
label=_('New password confirmation'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
@@ -5,19 +5,14 @@ from crispy_forms.layout import Submit
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base import Form
|
||||
from .base import ConfirmationMixin, Form
|
||||
|
||||
|
||||
class AssociationForm(Form):
|
||||
pass
|
||||
|
||||
|
||||
class ConfirmationForm(AssociationForm):
|
||||
canhazconfirm = forms.CharField(
|
||||
label='',
|
||||
required=True,
|
||||
widget=forms.HiddenInput,
|
||||
)
|
||||
class ConfirmationForm(ConfirmationMixin, AssociationForm):
|
||||
title = forms.CharField(
|
||||
label=_('Title'),
|
||||
required=False,
|
||||
|
||||
@@ -61,3 +61,11 @@ class Form(forms.Form):
|
||||
template=self.get_form_actions_template(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ConfirmationMixin(forms.Form):
|
||||
canhazconfirm = forms.CharField(
|
||||
label='',
|
||||
required=True,
|
||||
widget=forms.HiddenInput,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import accounts # noqa: F401
|
||||
from . import saves # noqa: F401
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import access_tokens # noqa: F401
|
||||
from . import auth # noqa: F401
|
||||
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from bthlabs_jsonrpc_core import register_method
|
||||
from django import db
|
||||
from django.http import HttpRequest
|
||||
|
||||
from hotpocket_soa.services import AccessTokensService
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_method('accounts.access_tokens.create')
|
||||
def create(request: HttpRequest,
|
||||
auth_key: str,
|
||||
meta: dict,
|
||||
) -> str:
|
||||
with db.transaction.atomic():
|
||||
try:
|
||||
assert 'extension_auth_key' in request.session, 'Auth key missing'
|
||||
assert request.session['extension_auth_key'] == auth_key, (
|
||||
'Auth key mismatch'
|
||||
)
|
||||
except AssertionError as exception:
|
||||
LOGGER.error(
|
||||
'Unable to issue access token: %s',
|
||||
exception,
|
||||
exc_info=exception,
|
||||
)
|
||||
raise
|
||||
|
||||
access_token = AccessTokensService().create(
|
||||
account_uuid=request.user.pk,
|
||||
origin=request.META['HTTP_ORIGIN'],
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
request.session.pop('extension_auth_key')
|
||||
request.session.save()
|
||||
|
||||
return access_token.key
|
||||
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from bthlabs_jsonrpc_core import register_method
|
||||
from django.http import HttpRequest
|
||||
|
||||
|
||||
@register_method('accounts.auth.check')
|
||||
def check(request: HttpRequest) -> bool:
|
||||
return request.user.is_anonymous is False
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from bthlabs_jsonrpc_core import register_method
|
||||
from django import db
|
||||
from django.http import HttpRequest
|
||||
|
||||
from hotpocket_backend.apps.ui.services.workflows import CreateSaveWorkflow
|
||||
from hotpocket_soa.dto.associations import AssociationOut
|
||||
|
||||
|
||||
@register_method(method='saves.create')
|
||||
def create(request: HttpRequest, url: str) -> AssociationOut:
|
||||
with db.transaction.atomic():
|
||||
association = CreateSaveWorkflow().run_rpc(
|
||||
request=request,
|
||||
account=request.user,
|
||||
url=url,
|
||||
)
|
||||
|
||||
return association
|
||||
@@ -1,3 +1,4 @@
|
||||
from .access_tokens import UIAccessTokensService # noqa: F401
|
||||
from .associations import UIAssociationsService # noqa: F401
|
||||
from .imports import UIImportsService # noqa: F401
|
||||
from .saves import UISavesService # noqa: F401
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404
|
||||
|
||||
from hotpocket_soa.dto.accounts import AccessTokenOut
|
||||
from hotpocket_soa.services import AccessTokensService
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UIAccessTokensService:
|
||||
def __init__(self):
|
||||
self.access_tokens_service = AccessTokensService()
|
||||
|
||||
def get_or_404(self,
|
||||
*,
|
||||
account_uuid: uuid.UUID,
|
||||
pk: uuid.UUID,
|
||||
) -> AccessTokenOut:
|
||||
try:
|
||||
return AccessTokensService().get(
|
||||
account_uuid=account_uuid,
|
||||
pk=pk,
|
||||
)
|
||||
except AccessTokensService.AccessTokenNotFound as exception:
|
||||
LOGGER.error(
|
||||
'Access Token not found: account_uuid=`%s` pk=`%s`',
|
||||
account_uuid,
|
||||
pk,
|
||||
exc_info=exception,
|
||||
)
|
||||
raise Http404()
|
||||
except AccessTokensService.AccessTokenAccessDenied as exception:
|
||||
LOGGER.error(
|
||||
'Access Token access denied: account_uuid=`%s` pk=`%s`',
|
||||
account_uuid,
|
||||
pk,
|
||||
exc_info=exception,
|
||||
)
|
||||
raise PermissionDenied()
|
||||
@@ -1,7 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from bthlabs_jsonrpc_core import JSONRPCInternalError
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
import django.db
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
@@ -9,19 +11,19 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from hotpocket_backend.apps.accounts.types import PAccount
|
||||
from hotpocket_soa.dto.saves import SaveIn
|
||||
from hotpocket_soa.dto.associations import AssociationOut
|
||||
from hotpocket_soa.dto.celery import AsyncResultOut
|
||||
from hotpocket_soa.dto.saves import SaveIn, SaveOut
|
||||
from hotpocket_soa.services import SavesService
|
||||
|
||||
from .base import SaveWorkflow
|
||||
|
||||
|
||||
class CreateSaveWorkflow(SaveWorkflow):
|
||||
def run(self,
|
||||
*,
|
||||
request: HttpRequest,
|
||||
account: PAccount,
|
||||
url: str,
|
||||
force_post_save: bool = False,
|
||||
) -> HttpResponse:
|
||||
def create_associate_and_process(self,
|
||||
account: PAccount,
|
||||
url: str,
|
||||
) -> tuple[SaveOut, AssociationOut, AsyncResultOut | None]:
|
||||
with django.db.transaction.atomic():
|
||||
save = self.create(
|
||||
account.pk,
|
||||
@@ -30,6 +32,23 @@ class CreateSaveWorkflow(SaveWorkflow):
|
||||
|
||||
association = self.associate(account.pk, save)
|
||||
|
||||
processing_result: AsyncResultOut | None = None
|
||||
if save.last_processed_at is None:
|
||||
processing_result = self.schedule_processing(save)
|
||||
|
||||
return (save, association, processing_result)
|
||||
|
||||
def run(self,
|
||||
*,
|
||||
request: HttpRequest,
|
||||
account: PAccount,
|
||||
url: str,
|
||||
force_post_save: bool = False,
|
||||
) -> HttpResponse:
|
||||
save, association, processing_result = self.create_associate_and_process(
|
||||
account, url,
|
||||
)
|
||||
|
||||
response = redirect(reverse('ui.associations.browse'))
|
||||
if force_post_save is True or save.is_netloc_banned is True:
|
||||
response = redirect(reverse(
|
||||
@@ -46,7 +65,22 @@ class CreateSaveWorkflow(SaveWorkflow):
|
||||
response.headers['X-HotPocket-Testing-Save-PK'] = save.pk
|
||||
response.headers['X-HotPocket-Testing-Association-PK'] = association.pk
|
||||
|
||||
if save.last_processed_at is None:
|
||||
processing_result = self.schedule_processing(save) # noqa: F841
|
||||
|
||||
return response
|
||||
|
||||
def run_rpc(self,
|
||||
*,
|
||||
request: HttpRequest,
|
||||
account: PAccount,
|
||||
url: str,
|
||||
) -> AssociationOut:
|
||||
try:
|
||||
save, association, processing_result = self.create_associate_and_process(
|
||||
account, url,
|
||||
)
|
||||
|
||||
return association
|
||||
except SavesService.SavesServiceError as exception:
|
||||
if isinstance(exception.__cause__, ValidationError) is True:
|
||||
raise JSONRPCInternalError(data=exception.__cause__)
|
||||
|
||||
raise
|
||||
|
||||
@@ -36,7 +36,7 @@ body:not(.ui-js-enabled) .ui-noscript-hide {
|
||||
}
|
||||
|
||||
#navbar .ui-navbar-brand > img {
|
||||
border-radius: 0.25rem;
|
||||
border-radius: 20%;
|
||||
height: 1.5rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
@@ -45,11 +45,11 @@ body:not(.ui-js-enabled) .ui-noscript-hide {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ui-save-card .card-footer .spinner-border {
|
||||
.spinner-border.ui-htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-save-card .card-footer .spinner-border.htmx-request {
|
||||
.spinner-border.ui-htmx-indicator.htmx-request {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 874 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,63 @@
|
||||
/*!
|
||||
* HotPocket by BTHLabs (https://hotpocket.app/)
|
||||
* Copyright 2025-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
((HotPocket, htmx) => {
|
||||
class BrowseAccountAppsView {
|
||||
constructor (app) {
|
||||
this.app = app;
|
||||
}
|
||||
onLoad = (event) => {
|
||||
document.addEventListener(
|
||||
'HotPocket:BrowseAccountAppsView:updateLoadMoreButton',
|
||||
this.onUpdateLoadMoreButton,
|
||||
);
|
||||
|
||||
document.addEventListener(
|
||||
'HotPocket:BrowseAccountAppsView:delete',
|
||||
this.onDelete,
|
||||
);
|
||||
};
|
||||
onUpdateLoadMoreButton = (event) => {
|
||||
const button = document.querySelector('#BrowseAccountAppsView .ui-load-more-button');
|
||||
if (button) {
|
||||
if (event.detail.next_url) {
|
||||
button.setAttribute('hx-get', event.detail.next_url);
|
||||
button.classList.remove('disable');
|
||||
button.removeAttribute('disabled', true);
|
||||
} else {
|
||||
button.classList.add('disable');
|
||||
button.setAttribute('disabled', true);
|
||||
}
|
||||
|
||||
htmx.process('#BrowseAccountAppsView .ui-load-more-button');
|
||||
}
|
||||
};
|
||||
onDelete = (event) => {
|
||||
if (event.detail && event.detail.pk) {
|
||||
const elementsToRemove = document.querySelectorAll(
|
||||
`[data-access-token="${event.detail.pk}"]`,
|
||||
);
|
||||
for (let elementToRemove of elementsToRemove) {
|
||||
elementToRemove.remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
HotPocket.addPlugin('UI.BrowseAccountAppsView', (app) => {
|
||||
return new BrowseAccountAppsView(app);
|
||||
});
|
||||
})(window.HotPocket, window.htmx);
|
||||
@@ -20,21 +20,25 @@
|
||||
this.app = app;
|
||||
}
|
||||
onLoad = (event) => {
|
||||
document.addEventListener('HotPocket:BrowseSavesView:updateLoadMoreButton', (event) => {
|
||||
const button = document.querySelector('#BrowseSavesView .ui-load-more-button');
|
||||
if (button) {
|
||||
if (event.detail.next_url) {
|
||||
button.setAttribute('hx-get', event.detail.next_url);
|
||||
button.classList.remove('disable');
|
||||
button.removeAttribute('disabled', true);
|
||||
} else {
|
||||
button.classList.add('disable');
|
||||
button.setAttribute('disabled', true);
|
||||
}
|
||||
|
||||
htmx.process('#BrowseSavesView .ui-load-more-button');
|
||||
document.addEventListener(
|
||||
'HotPocket:BrowseSavesView:updateLoadMoreButton',
|
||||
this.onUpdateLoadMoreButton,
|
||||
);
|
||||
};
|
||||
onUpdateLoadMoreButton = (event) => {
|
||||
const button = document.querySelector('#BrowseSavesView .ui-load-more-button');
|
||||
if (button) {
|
||||
if (event.detail.next_url) {
|
||||
button.setAttribute('hx-get', event.detail.next_url);
|
||||
button.classList.remove('disable');
|
||||
button.removeAttribute('disabled', true);
|
||||
} else {
|
||||
button.classList.add('disable');
|
||||
button.setAttribute('disabled', true);
|
||||
}
|
||||
});
|
||||
|
||||
htmx.process('#BrowseSavesView .ui-load-more-button');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load crispy_forms_tags i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'Apps' %} | {% translate 'Account' %}{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div id="BrowseAccountAppsView" class="container">
|
||||
<p class="display-6 my-3">{% translate 'Apps' %}</p>
|
||||
|
||||
{% include 'ui/accounts/partials/nav.html' with active_tab='apps' %}
|
||||
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% translate 'App' %}</td>
|
||||
<td>{% translate 'Platform' %}</td>
|
||||
<td>{% translate 'Version' %}</td>
|
||||
<td>{% translate 'Authorized at' %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="ui-account-apps">
|
||||
{% include 'ui/accounts/partials/apps/apps.html' with access_tokens=access_tokens params=params %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="my-3 text-center {% if not access_tokens and params.before is None %}d-none{% endif %}">
|
||||
<button
|
||||
class="btn btn-primary {% if not before %}disabled{% endif %} ui-noscript-hide ui-load-more-button"
|
||||
hx-get="{{ next_url }}"
|
||||
hx-push-url="true"
|
||||
hx-swap="beforeend"
|
||||
hx-target="#BrowseAccountAppsView .ui-account-apps"
|
||||
>
|
||||
{% translate 'Load more' %}
|
||||
</button>
|
||||
|
||||
<a
|
||||
class="btn btn-primary {% if not before %}disabled{% endif %} ui-noscript-show"
|
||||
href="{{ next_url }}"
|
||||
>
|
||||
{% translate 'Load more' %}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<template id="BrowseAccountAppsView-DeleteModal">
|
||||
<div class="modal fade" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{% translate 'Delete an app authorization?' %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Close' %}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% include 'ui/accounts/partials/apps/delete_confirmation.html' with alert_class="mb-0" %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate 'Cancel' %}</button>
|
||||
<button type="button" class="btn btn-danger" data-ui-modal-action="confirm">
|
||||
{% translate 'Delete' %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load crispy_forms_tags i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'Delete an app authorization?' %} | {% translate 'Account' %}{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div id="BrowseAccountAppsView" class="container">
|
||||
<p class="display-6 my-3">{% translate 'Delete an app authorization?' %}</p>
|
||||
|
||||
{% include 'ui/accounts/partials/nav.html' with active_tab='apps' %}
|
||||
|
||||
{% include 'ui/accounts/partials/apps/delete_confirmation.html' %}
|
||||
|
||||
{% crispy form %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% load i18n static ui %}
|
||||
|
||||
{% for access_token in access_tokens %}
|
||||
<tr data-access-token="{{ access_token.pk }}">
|
||||
<td>{{ access_token|render_access_token_app }}</td>
|
||||
<td>{{ access_token|render_access_token_platform }}</td>
|
||||
<td><code>{{ access_token.meta.version }}</code></td>
|
||||
<td>{{ access_token.created_at }}</td>
|
||||
</tr>
|
||||
<tr data-access-token="{{ access_token.pk }}">
|
||||
<td colspan="4">
|
||||
<div class="d-flex justify-content-end align-items-center">
|
||||
<div class="spinner-border spinner-border-sm ui-htmx-indicator" role="status">
|
||||
<span class="visually-hidden">{% translate 'Processing' %}</span>
|
||||
</div>
|
||||
<a
|
||||
class="btn btn-sm btn-danger ms-2"
|
||||
data-ui-modal="#BrowseAccountAppsView-DeleteModal"
|
||||
hx-confirm='CANHAZCONFIRM'
|
||||
hx-indicator='[data-access-token="{{ access_token.pk }}"] .ui-htmx-indicator'
|
||||
hx-post="{% url 'ui.accounts.apps.delete' pk=access_token.pk %}"
|
||||
hx-swap="delete"
|
||||
hx-target='[data-access-token="{{ access_token.pk }}"]'
|
||||
hx-vars='{"canhazconfirm":true}'
|
||||
href="{% url 'ui.accounts.apps.delete' pk=access_token.pk %}"
|
||||
>
|
||||
<i class="bi bi-trash3-fill"></i> {% translate 'Delete' %}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
{% if not HTMX %}
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
{% if params.before is None %}
|
||||
<strong>{% translate "You haven't authorized any apps yet." %}</strong>
|
||||
{% else %}
|
||||
<span>{% translate "You've reached the end of the line." %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% load i18n static ui %}
|
||||
|
||||
<div class="alert alert-danger {{ alert_class|default_if_none:'' }}">
|
||||
<h4 class="alert-heading">{% translate 'Point of no return' %}</h4>
|
||||
<p class="lead mb-0">{% translate 'Are you sure you want to delete this app authorization?' %}</p>
|
||||
<p class="mb-0"><strong>You'll need to authorize again if you ever change your mind.</strong></p>
|
||||
</div>
|
||||
@@ -5,7 +5,7 @@
|
||||
{% if active_tab == 'profile' %}
|
||||
<a class="nav-link active" aria-current="page" href="#">
|
||||
{% else %}
|
||||
<a class="nav-link" aria-current="page" href="{% url 'ui.accounts.settings.profile' %}">
|
||||
<a class="nav-link" href="{% url 'ui.accounts.settings.profile' %}">
|
||||
{% endif %}
|
||||
{% translate 'Profile' %}
|
||||
</a>
|
||||
@@ -14,7 +14,7 @@
|
||||
{% if active_tab == 'password' %}
|
||||
<a class="nav-link active" aria-current="page" href="#">
|
||||
{% else %}
|
||||
<a class="nav-link" aria-current="page" href="{% url 'ui.accounts.settings.password' %}">
|
||||
<a class="nav-link" href="{% url 'ui.accounts.settings.password' %}">
|
||||
{% endif %}
|
||||
{% translate 'Password' %}
|
||||
</a>
|
||||
@@ -23,9 +23,18 @@
|
||||
{% if active_tab == 'settings' %}
|
||||
<a class="nav-link active" aria-current="page" href="#">
|
||||
{% else %}
|
||||
<a class="nav-link" aria-current="page" href="{% url 'ui.accounts.settings.settings' %}">
|
||||
<a class="nav-link" href="{% url 'ui.accounts.settings.settings' %}">
|
||||
{% endif %}
|
||||
{% translate 'Settings' %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
{% if active_tab == 'apps' %}
|
||||
<a class="nav-link active" aria-current="page" href="#">
|
||||
{% else %}
|
||||
<a class="nav-link" href="{% url 'ui.accounts.apps.index' %}">
|
||||
{% endif %}
|
||||
{% translate 'Apps' %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<a href="{{ association.target.url }}" target="_blank" rel="noopener noreferer"><small>{{ association.target.url|render_url_domain }}</small></a>
|
||||
<div class="ms-auto flex-shrink-0 d-flex align-items-center">
|
||||
{% if not association.archived_at %}
|
||||
<div class="spinner-border spinner-border-sm" role="status">
|
||||
<div class="spinner-border spinner-border-sm ui-htmx-indicator" role="status">
|
||||
<span class="visually-hidden">{% translate 'Processing' %}</span>
|
||||
</div>
|
||||
{% if association.is_starred %}
|
||||
@@ -35,7 +35,7 @@
|
||||
<a
|
||||
class="dropdown-item"
|
||||
hx-get="{% url 'ui.associations.unstar' pk=association.pk %}"
|
||||
hx-indicator='[data-association="{{ association.pk }}"] .spinner-border'
|
||||
hx-indicator='[data-association="{{ association.pk }}"] .ui-htmx-indicator'
|
||||
hx-swap="innerHTML"
|
||||
hx-target='[data-association="{{ association.pk }}"]'
|
||||
href="{% url 'ui.associations.unstar' pk=association.pk %}"
|
||||
@@ -48,7 +48,7 @@
|
||||
<a
|
||||
class="dropdown-item"
|
||||
hx-get="{% url 'ui.associations.star' pk=association.pk %}"
|
||||
hx-indicator='[data-association="{{ association.pk }}"] .spinner-border'
|
||||
hx-indicator='[data-association="{{ association.pk }}"] .ui-htmx-indicator'
|
||||
hx-swap="innerHTML"
|
||||
hx-target='[data-association="{{ association.pk }}"]'
|
||||
href="{% url 'ui.associations.star' pk=association.pk %}"
|
||||
@@ -70,7 +70,7 @@
|
||||
class="dropdown-item text-warning"
|
||||
data-ui-modal="#BrowseSavesView-RefreshModal"
|
||||
hx-confirm='CANHAZCONFIRM'
|
||||
hx-indicator='[data-association="{{ association.pk }}"] .spinner-border'
|
||||
hx-indicator='[data-association="{{ association.pk }}"] .ui-htmx-indicator'
|
||||
hx-post="{% url 'ui.associations.refresh' pk=association.pk %}"
|
||||
hx-swap="none"
|
||||
hx-target='[data-association="{{ association.pk }}"]'
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'Redirecting back to the extension...' %}{% endblock %}
|
||||
|
||||
{% block button_bar_class %}d-none{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div class="container">
|
||||
<div class="alert alert-success mt-3" role="alert">
|
||||
<h4 class="alert-heading">{% translate 'Done!' %}</h4>
|
||||
<p class="lead mb-0">
|
||||
{% translate "You've successfully logged in to the extension." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -10,7 +10,7 @@
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle ui-navbar-brand" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{% spaceless %}
|
||||
<img src="{% static 'ui/img/icon-180.png' %}">
|
||||
<img src="{% static 'ui/img/icon-48.png' %}">
|
||||
<span class="ms-2">{% block top_nav_title %}HotPocket{% endblock %}</span>
|
||||
{% endspaceless %}
|
||||
</a>
|
||||
@@ -35,7 +35,7 @@
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link pe-none ui-navbar-brand">
|
||||
<img src="{% static 'ui/img/icon-180.png' %}">
|
||||
<img src="{% static 'ui/img/icon-48.png' %}">
|
||||
<span class="ms-2">{{ SITE_TITLE }}</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -146,6 +146,7 @@
|
||||
<script src="{% static 'ui/js/hotpocket-backend.ui.Modal.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/hotpocket-backend.ui.BrowseSavesView.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/hotpocket-backend.ui.ViewAssociationView.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/hotpocket-backend.ui.BrowseAccountAppsView.js' %}" type="text/javascript"></script>
|
||||
{% block page_scripts %}{% endblock %}
|
||||
<script type="text/javascript">
|
||||
(() => {
|
||||
|
||||
@@ -8,10 +8,19 @@ import urllib.parse
|
||||
from django import template
|
||||
from django.contrib.messages import Message
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from hotpocket_backend.apps.ui.constants import MessageLevelAlertClass
|
||||
from hotpocket_backend.apps.ui.constants import (
|
||||
MessageLevelAlertClass,
|
||||
UIAccessTokenOriginApp,
|
||||
)
|
||||
from hotpocket_backend.apps.ui.dto.base import BrowseParams
|
||||
from hotpocket_common.constants import AssociationsSearchMode
|
||||
from hotpocket_common.constants import (
|
||||
AccessTokenOriginApp,
|
||||
AssociationsSearchMode,
|
||||
)
|
||||
from hotpocket_soa.dto.accounts import AccessTokenOut
|
||||
from hotpocket_soa.dto.saves import SaveOut
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -115,3 +124,49 @@ def alert_class(message: Message | None) -> str:
|
||||
)
|
||||
|
||||
return 'alert-secondary'
|
||||
|
||||
|
||||
@register.filter(name='render_access_token_app')
|
||||
def render_access_token_app(access_token: AccessTokenOut) -> str:
|
||||
app: str = access_token.get_origin_app_id()
|
||||
variant = 'secondary'
|
||||
|
||||
origin_app = access_token.get_origin_app()
|
||||
match origin_app:
|
||||
case AccessTokenOriginApp.SAFARI_WEB_EXTENSION:
|
||||
app = UIAccessTokenOriginApp[origin_app.value].value
|
||||
variant = 'info'
|
||||
|
||||
return format_html(
|
||||
'<span class="badge text-bg-{}">{}</span>',
|
||||
variant,
|
||||
app,
|
||||
)
|
||||
|
||||
|
||||
@register.filter(name='render_access_token_platform')
|
||||
def render_access_token_platform(access_token: AccessTokenOut) -> str:
|
||||
match access_token.meta.get('platform', None):
|
||||
case 'MacIntel':
|
||||
return 'macOS'
|
||||
|
||||
case 'iPhone':
|
||||
return 'iOS'
|
||||
|
||||
case 'iPad':
|
||||
return 'iPadOS'
|
||||
|
||||
case 'Win32':
|
||||
return 'Windows'
|
||||
|
||||
case 'Linux x86_64':
|
||||
return 'Linux'
|
||||
|
||||
case 'Linux armv81':
|
||||
return 'Linux'
|
||||
|
||||
case None:
|
||||
return _('Unknown')
|
||||
|
||||
case _:
|
||||
return access_token.meta['platform']
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from bthlabs_jsonrpc_django import is_authenticated
|
||||
from django.urls import path
|
||||
|
||||
from hotpocket_backend.apps.core.rpc import JSONRPCView
|
||||
from hotpocket_backend.apps.ui.constants import StarUnstarAssociationViewMode
|
||||
|
||||
# isort: off
|
||||
@@ -20,33 +22,44 @@ from .views import (
|
||||
urlpatterns = [
|
||||
path(
|
||||
'accounts/login/',
|
||||
accounts.LoginView.as_view(),
|
||||
accounts.auth.LoginView.as_view(),
|
||||
name='ui.accounts.login',
|
||||
),
|
||||
path(
|
||||
'accounts/post-login/',
|
||||
accounts.PostLoginView.as_view(),
|
||||
accounts.auth.PostLoginView.as_view(),
|
||||
name='ui.accounts.post_login',
|
||||
),
|
||||
path('accounts/logout/', accounts.logout, name='ui.accounts.logout'),
|
||||
path('accounts/browse/', accounts.browse, name='ui.accounts.browse'),
|
||||
path('accounts/settings/', accounts.settings, name='ui.accounts.settings'),
|
||||
path('accounts/logout/', accounts.auth.logout, name='ui.accounts.logout'),
|
||||
path('accounts/browse/', accounts.browse.browse, name='ui.accounts.browse'),
|
||||
path('accounts/settings/', accounts.settings.settings, name='ui.accounts.settings'),
|
||||
path(
|
||||
'accounts/settings/profile/',
|
||||
accounts.ProfileView.as_view(),
|
||||
accounts.settings.ProfileView.as_view(),
|
||||
name='ui.accounts.settings.profile',
|
||||
),
|
||||
path(
|
||||
'accounts/settings/password/',
|
||||
accounts.PasswordView.as_view(),
|
||||
accounts.settings.PasswordView.as_view(),
|
||||
name='ui.accounts.settings.password',
|
||||
),
|
||||
path(
|
||||
'accounts/settings/settings/',
|
||||
accounts.SettingsView.as_view(),
|
||||
accounts.settings.SettingsView.as_view(),
|
||||
name='ui.accounts.settings.settings',
|
||||
),
|
||||
path('accounts/', accounts.index, name='ui.accounts.index'),
|
||||
path('accounts/apps/', accounts.apps.index, name='ui.accounts.apps.index'),
|
||||
path(
|
||||
'accounts/apps/browse/',
|
||||
accounts.apps.browse,
|
||||
name='ui.accounts.apps.browse',
|
||||
),
|
||||
path(
|
||||
'accounts/apps/delete/<str:pk>',
|
||||
accounts.apps.DeleteView.as_view(),
|
||||
name='ui.accounts.apps.delete',
|
||||
),
|
||||
path('accounts/', accounts.index.index, name='ui.accounts.index'),
|
||||
path(
|
||||
'imports/pocket/',
|
||||
imports.PocketImportView.as_view(),
|
||||
@@ -62,6 +75,16 @@ urlpatterns = [
|
||||
integrations.android.share_sheet,
|
||||
name='ui.integrations.android.share_sheet',
|
||||
),
|
||||
path(
|
||||
'integrations/extension/authenticate/',
|
||||
integrations.extension.authenticate,
|
||||
name='ui.integrations.extension.authenticate',
|
||||
),
|
||||
path(
|
||||
'integrations/extension/post-authenticate/',
|
||||
integrations.extension.post_authenticate,
|
||||
name='ui.integrations.extension.post_authenticate',
|
||||
),
|
||||
path(
|
||||
'saves/create/',
|
||||
saves.CreateView.as_view(),
|
||||
@@ -107,5 +130,12 @@ urlpatterns = [
|
||||
),
|
||||
path('associations/', associations.index, name='ui.associations.index'),
|
||||
path('manifest.json', meta.manifest_json, name='ui.meta.manifest_json'),
|
||||
path(
|
||||
'rpc/',
|
||||
JSONRPCView.as_view(
|
||||
auth_checks=[is_authenticated],
|
||||
),
|
||||
name='ui.rpc',
|
||||
),
|
||||
path('', index.index, name='ui.index.index'),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from . import apps # noqaz: F401
|
||||
from . import auth # noqa: F401
|
||||
from . import browse # noqa: F401
|
||||
from . import index # noqa: F401
|
||||
from . import settings # noqa: F401
|
||||
@@ -0,0 +1,167 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from django.contrib import messages
|
||||
import django.db
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import FormView
|
||||
from django_htmx.http import trigger_client_event
|
||||
|
||||
from hotpocket_backend.apps.accounts.decorators import account_required
|
||||
from hotpocket_backend.apps.accounts.mixins import AccountRequiredMixin
|
||||
from hotpocket_backend.apps.htmx import messages as htmx_messages
|
||||
from hotpocket_backend.apps.ui.constants import UIAccessTokenOriginApp
|
||||
from hotpocket_backend.apps.ui.dto.accounts import AppsBrowseParams
|
||||
from hotpocket_backend.apps.ui.forms.accounts.apps import DeleteForm
|
||||
from hotpocket_backend.apps.ui.services import UIAccessTokensService
|
||||
from hotpocket_soa.dto.accounts import AccessTokenOut, AccessTokensQuery
|
||||
from hotpocket_soa.services import AccessTokensService
|
||||
|
||||
|
||||
class AccessTokenMixin:
|
||||
def get_access_token(self) -> AccessTokenOut:
|
||||
if hasattr(self, '_access_token') is False:
|
||||
setattr(
|
||||
self,
|
||||
'_access_token',
|
||||
UIAccessTokensService().get_or_404(
|
||||
account_uuid=self.request.user.pk, # type: ignore[attr-defined]
|
||||
pk=self.kwargs['pk'], # type: ignore[attr-defined]
|
||||
),
|
||||
)
|
||||
|
||||
return self._access_token # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class DetailView(AccessTokenMixin, AccountRequiredMixin, FormView):
|
||||
def get_context_data(self, **kwargs) -> dict:
|
||||
result = super().get_context_data(**kwargs)
|
||||
|
||||
result.update({
|
||||
'access_token': self.get_access_token(),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ConfirmationView(DetailView):
|
||||
def get_initial(self) -> dict:
|
||||
result = super().get_initial()
|
||||
|
||||
access_token: AccessTokenOut = self.get_access_token()
|
||||
|
||||
origin_app = access_token.get_origin_app()
|
||||
if origin_app is not None:
|
||||
origin_app = UIAccessTokenOriginApp[origin_app.value].value
|
||||
|
||||
result.update({
|
||||
'canhazconfirm': 'hai',
|
||||
'origin_app': origin_app or access_token.get_origin_app_id(),
|
||||
'platform': access_token.meta.get('platform', '-'),
|
||||
'version': access_token.meta.get('version', '-'),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('ui.accounts.apps.browse')
|
||||
|
||||
|
||||
@account_required
|
||||
def index(request: HttpRequest) -> HttpResponse:
|
||||
return redirect(reverse('ui.accounts.apps.browse'))
|
||||
|
||||
|
||||
@account_required
|
||||
def browse(request: HttpRequest) -> HttpResponse:
|
||||
params = AppsBrowseParams.from_request(request=request)
|
||||
|
||||
access_tokens = AccessTokensService().search(
|
||||
query=AccessTokensQuery.model_validate(dict(
|
||||
account_uuid=request.user.pk,
|
||||
before=params.before,
|
||||
)),
|
||||
limit=params.limit,
|
||||
)
|
||||
|
||||
before: uuid.UUID | None = None
|
||||
if len(access_tokens) == params.limit:
|
||||
before = access_tokens[-1].pk
|
||||
|
||||
next_url: str | None = None
|
||||
if before is not None:
|
||||
next_url = reverse('ui.accounts.apps.browse', query=[
|
||||
('before', before),
|
||||
('limit', params.limit),
|
||||
])
|
||||
|
||||
context = {
|
||||
'access_tokens': access_tokens,
|
||||
'params': params,
|
||||
'before': before,
|
||||
'next_url': next_url,
|
||||
}
|
||||
|
||||
if request.htmx:
|
||||
response = render(
|
||||
request,
|
||||
'ui/accounts/partials/apps/apps.html',
|
||||
context,
|
||||
)
|
||||
|
||||
return trigger_client_event(
|
||||
response,
|
||||
'HotPocket:BrowseAccountAppsView:updateLoadMoreButton',
|
||||
{'next_url': next_url},
|
||||
after='swap',
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
'ui/accounts/apps/browse.html',
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
class DeleteView(ConfirmationView):
|
||||
template_name = 'ui/accounts/apps/delete.html'
|
||||
form_class = DeleteForm
|
||||
|
||||
def form_valid(self, form: DeleteForm) -> HttpResponse:
|
||||
with django.db.transaction.atomic():
|
||||
result = AccessTokensService().delete(
|
||||
access_token=self.get_access_token(),
|
||||
)
|
||||
|
||||
if self.request.htmx:
|
||||
response = JsonResponse({
|
||||
'status': 'ok',
|
||||
'result': result,
|
||||
})
|
||||
|
||||
htmx_messages.add_htmx_message(
|
||||
request=self.request,
|
||||
response=response,
|
||||
level=htmx_messages.SUCCESS,
|
||||
message=_('The app auhtorization has been deleted.'),
|
||||
)
|
||||
|
||||
return trigger_client_event(
|
||||
response,
|
||||
'HotPocket:BrowseAccountAppsView:delete',
|
||||
{'pk': self.kwargs['pk']},
|
||||
after='swap',
|
||||
)
|
||||
|
||||
messages.add_message(
|
||||
self.request,
|
||||
messages.SUCCESS,
|
||||
_('The app auhtorization has been deleted.'),
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
@@ -0,0 +1,66 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth import logout as auth_logout
|
||||
from django.contrib.auth.views import LoginView as BaseLoginView
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
from hotpocket_backend.apps.core.conf import settings as django_settings
|
||||
from hotpocket_backend.apps.ui.forms.accounts.auth import LoginForm
|
||||
|
||||
|
||||
class LoginView(BaseLoginView):
|
||||
template_name = 'ui/accounts/login.html'
|
||||
form_class = LoginForm
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
request.session['post_login_next_url'] = request.GET.get('next', None)
|
||||
request.session.save()
|
||||
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('ui.accounts.post_login')
|
||||
|
||||
|
||||
class PostLoginView(RedirectView):
|
||||
def get_redirect_url(self, *args, **kwargs) -> str:
|
||||
next_url = self.request.session.pop('post_login_next_url', None)
|
||||
self.request.session.save()
|
||||
|
||||
allowed_hosts = None
|
||||
if len(django_settings.ALLOWED_HOSTS) > 0:
|
||||
allowed_hosts = set(filter(
|
||||
lambda value: value != '*',
|
||||
django_settings.ALLOWED_HOSTS,
|
||||
))
|
||||
|
||||
if next_url is not None:
|
||||
next_url_is_safe = url_has_allowed_host_and_scheme(
|
||||
url=next_url,
|
||||
allowed_hosts=allowed_hosts,
|
||||
require_https=self.request.is_secure(),
|
||||
)
|
||||
|
||||
if next_url_is_safe is False:
|
||||
next_url = None
|
||||
|
||||
return next_url or reverse('ui.index.index')
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
if request.user.is_anonymous is True:
|
||||
raise PermissionDenied('NOPE')
|
||||
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
def logout(request: HttpRequest) -> HttpResponse:
|
||||
if request.user.is_authenticated is True:
|
||||
auth_logout(request)
|
||||
|
||||
return render(request, 'ui/accounts/logout.html')
|
||||
@@ -0,0 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
|
||||
from hotpocket_backend.apps.accounts.decorators import account_required
|
||||
|
||||
|
||||
@account_required
|
||||
def browse(request: HttpRequest) -> HttpResponse:
|
||||
raise Http404()
|
||||
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
|
||||
from hotpocket_backend.apps.accounts.decorators import account_required
|
||||
|
||||
|
||||
@account_required
|
||||
def index(request: HttpRequest) -> HttpResponse:
|
||||
return redirect(reverse('ui.accounts.settings'))
|
||||
@@ -2,92 +2,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import logout as auth_logout
|
||||
from django.contrib.auth.views import LoginView as BaseLoginView
|
||||
from django.core.exceptions import PermissionDenied
|
||||
import django.db
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import FormView, RedirectView
|
||||
from django.views.generic import FormView
|
||||
|
||||
from hotpocket_backend.apps.accounts.decorators import account_required
|
||||
from hotpocket_backend.apps.accounts.mixins import AccountRequiredMixin
|
||||
from hotpocket_backend.apps.core.conf import settings as django_settings
|
||||
from hotpocket_backend.apps.ui.forms.accounts import (
|
||||
from hotpocket_backend.apps.ui.forms.accounts.settings import (
|
||||
FederatedPasswordForm,
|
||||
FederatedProfileForm,
|
||||
LoginForm,
|
||||
PasswordForm,
|
||||
ProfileForm,
|
||||
SettingsForm,
|
||||
)
|
||||
|
||||
|
||||
@account_required
|
||||
def index(request: HttpRequest) -> HttpResponse:
|
||||
return redirect(reverse('ui.accounts.settings'))
|
||||
|
||||
|
||||
@account_required
|
||||
def browse(request: HttpRequest) -> HttpResponse:
|
||||
raise Http404()
|
||||
|
||||
|
||||
class LoginView(BaseLoginView):
|
||||
template_name = 'ui/accounts/login.html'
|
||||
form_class = LoginForm
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
request.session['post_login_next_url'] = request.GET.get('next', None)
|
||||
request.session.save()
|
||||
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('ui.accounts.post_login')
|
||||
|
||||
|
||||
class PostLoginView(RedirectView):
|
||||
def get_redirect_url(self, *args, **kwargs) -> str:
|
||||
next_url = self.request.session.pop('post_login_next_url', None)
|
||||
self.request.session.save()
|
||||
|
||||
allowed_hosts = None
|
||||
if len(django_settings.ALLOWED_HOSTS) > 0:
|
||||
allowed_hosts = set(filter(
|
||||
lambda value: value != '*',
|
||||
django_settings.ALLOWED_HOSTS,
|
||||
))
|
||||
|
||||
if next_url is not None:
|
||||
next_url_is_safe = url_has_allowed_host_and_scheme(
|
||||
url=next_url,
|
||||
allowed_hosts=allowed_hosts,
|
||||
require_https=self.request.is_secure(),
|
||||
)
|
||||
|
||||
if next_url_is_safe is False:
|
||||
next_url = None
|
||||
|
||||
return next_url or reverse('ui.index.index')
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
if request.user.is_anonymous is True:
|
||||
raise PermissionDenied('NOPE')
|
||||
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
def logout(request: HttpRequest) -> HttpResponse:
|
||||
if request.user.is_authenticated is True:
|
||||
auth_logout(request)
|
||||
|
||||
return render(request, 'ui/accounts/logout.html')
|
||||
|
||||
|
||||
@account_required
|
||||
def settings(request: HttpRequest) -> HttpResponse:
|
||||
return redirect(reverse('ui.accounts.settings.profile'))
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import android # noqa: F401
|
||||
from . import extension # noqa: F401
|
||||
from . import ios # noqa: F401
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def authenticate(request: HttpRequest) -> HttpResponse:
|
||||
if request.user.is_anonymous is False:
|
||||
auth_key = str(uuid.uuid4())
|
||||
|
||||
request.session['extension_auth_key'] = auth_key
|
||||
request.session.save()
|
||||
|
||||
return redirect(reverse(
|
||||
'ui.integrations.extension.post_authenticate',
|
||||
query=[
|
||||
('auth_key', auth_key),
|
||||
],
|
||||
))
|
||||
|
||||
return redirect(reverse('ui.accounts.login', query=[
|
||||
('next', reverse('ui.integrations.extension.authenticate')),
|
||||
]))
|
||||
|
||||
|
||||
def post_authenticate(request: HttpRequest) -> HttpResponse:
|
||||
try:
|
||||
assert request.user.is_anonymous is False, 'Not authenticated'
|
||||
|
||||
auth_key = request.GET.get('auth_key', None)
|
||||
assert request.session.get('extension_auth_key', None) == auth_key, (
|
||||
'Auth key mismatch'
|
||||
)
|
||||
|
||||
return render(
|
||||
request, 'ui/integrations/extension/post_authenticate.html',
|
||||
)
|
||||
except AssertionError as exception:
|
||||
LOGGER.error(
|
||||
'Unable to handle extension authentication: %s',
|
||||
exception,
|
||||
exc_info=exception,
|
||||
)
|
||||
|
||||
raise PermissionDenied('NOPE')
|
||||
@@ -188,6 +188,7 @@ SITE_TITLE = 'HotPocket by BTHLabs'
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
'hotpocket_backend.apps.accounts.backends.AccessTokenBackend',
|
||||
]
|
||||
|
||||
IMAGE_ID = os.getenv('HOTPOCKET_BACKEND_IMAGE_ID', 'development.00000000')
|
||||
|
||||
@@ -4,9 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from corsheaders.defaults import default_headers
|
||||
|
||||
from .base import * # noqa: F401,F403
|
||||
|
||||
INSTALLED_APPS += [ # noqa: F405
|
||||
'bthlabs_jsonrpc_django',
|
||||
'corsheaders',
|
||||
'crispy_forms',
|
||||
'crispy_bootstrap5',
|
||||
'django_htmx',
|
||||
@@ -16,9 +20,11 @@ MIDDLEWARE = [
|
||||
'hotpocket_backend.apps.core.middleware.RequestIDMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'hotpocket_backend.apps.accounts.middleware.AccessTokenMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'social_django.middleware.SocialAuthExceptionMiddleware',
|
||||
@@ -29,6 +35,9 @@ ROOT_URLCONF = 'hotpocket_backend.urls.webapp'
|
||||
|
||||
LOGIN_REDIRECT_URL = '/accounts/post-login/'
|
||||
|
||||
SESSION_COOKIE_SAMESITE = 'None'
|
||||
SESSION_COOKIE_SECURE = True
|
||||
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap5'
|
||||
CRISPY_TEMPLATE_PACK = 'bootstrap5'
|
||||
|
||||
@@ -56,3 +65,16 @@ SAVES_ASSOCIATION_ADAPTER = os.environ.get(
|
||||
'HOTPOCKET_BACKEND_SAVES_ASSOCIATION_ADAPTER',
|
||||
'hotpocket_backend.apps.saves.adapters.basic:BasicAssociationAdapter',
|
||||
)
|
||||
|
||||
JSONRPC_METHOD_MODULES = [
|
||||
'hotpocket_backend.apps.ui.rpc_methods',
|
||||
]
|
||||
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = [
|
||||
r'safari-web-extension:\/\/.+?',
|
||||
]
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CORS_ALLOW_HEADERS = (
|
||||
*default_headers,
|
||||
'cookie',
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"eslint": "npx eslint"
|
||||
"eslint": "npx eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.33.0",
|
||||
|
||||
51
services/backend/poetry.lock
generated
@@ -103,6 +103,40 @@ urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >
|
||||
[package.extras]
|
||||
crt = ["awscrt (==0.23.8)"]
|
||||
|
||||
[[package]]
|
||||
name = "bthlabs-jsonrpc-core"
|
||||
version = "1.1.0"
|
||||
description = "BTHLabs JSONRPC - Core"
|
||||
optional = false
|
||||
python-versions = ">=3.10,<4.0"
|
||||
files = [
|
||||
{file = "bthlabs_jsonrpc_core-1.1.0-py3-none-any.whl", hash = "sha256:2ea4652a187c1406eb958c1cf64b24b4a475e817d46b506df6e5f6d14adf89b2"},
|
||||
{file = "bthlabs_jsonrpc_core-1.1.0.tar.gz", hash = "sha256:15280018689b06743e3dc08355428037d64dade9ac8f7bb59f232959884d1874"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
jwt = ["python-jose[cryptography] (>=3.3.0,<4.0)", "pytz (>=2023.3.post1)"]
|
||||
|
||||
[package.source]
|
||||
type = "legacy"
|
||||
url = "https://nexus.bthlabs.pl/repository/pypi/simple"
|
||||
reference = "nexus"
|
||||
|
||||
[[package]]
|
||||
name = "bthlabs-jsonrpc-django"
|
||||
version = "1.2.0"
|
||||
description = "BTHLabs JSONRPC - Django integration"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.10"
|
||||
files = [
|
||||
{file = "bthlabs_jsonrpc_django-1.2.0-py3-none-any.whl", hash = "sha256:a19c65bbc534de2cd1b98a7651ac25aad83e5e7d91381f8f0df3cf734351617e"},
|
||||
{file = "bthlabs_jsonrpc_django-1.2.0.tar.gz", hash = "sha256:a384d9a7f6ca151dfbf3fda828413a3c823551fc09b7e8a1bdc3aa3a208cf8f8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
bthlabs-jsonrpc-core = "1.1.0"
|
||||
django = ">=4.2,<5.3"
|
||||
|
||||
[[package]]
|
||||
name = "celery"
|
||||
version = "5.5.3"
|
||||
@@ -554,6 +588,21 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
argon2 = ["argon2-cffi (>=19.1.0)"]
|
||||
bcrypt = ["bcrypt"]
|
||||
|
||||
[[package]]
|
||||
name = "django-cors-headers"
|
||||
version = "4.7.0"
|
||||
description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "django_cors_headers-4.7.0-py3-none-any.whl", hash = "sha256:f1c125dcd58479fe7a67fe2499c16ee38b81b397463cf025f0e2c42937421070"},
|
||||
{file = "django_cors_headers-4.7.0.tar.gz", hash = "sha256:6fdf31bf9c6d6448ba09ef57157db2268d515d94fc5c89a0a1028e1fc03ee52b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
asgiref = ">=3.6"
|
||||
django = ">=4.2"
|
||||
|
||||
[[package]]
|
||||
name = "django-crispy-forms"
|
||||
version = "2.4"
|
||||
@@ -2287,4 +2336,4 @@ brotli = ["brotli"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "3d8cf7ddb06917472eed4724058178f36c17f40db891a19d13f5d0d758e61102"
|
||||
content-hash = "1daff90b61807314aa591cd4c4aa62107594c13d9126e2a3091eec541b9cd039"
|
||||
|
||||
@@ -6,11 +6,18 @@ authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
|
||||
license = "Apache-2.0"
|
||||
readme = "README.md"
|
||||
|
||||
[[tool.poetry.source]]
|
||||
name = "nexus"
|
||||
url = "https://nexus.bthlabs.pl/repository/pypi/simple/"
|
||||
priority = "supplemental"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
bthlabs-jsonrpc-django = "1.2.0"
|
||||
celery = "5.5.3"
|
||||
crispy-bootstrap5 = "2025.6"
|
||||
django = "5.2.3"
|
||||
django-cors-headers = "4.7.0"
|
||||
django-crispy-forms = "2.4"
|
||||
django-htmx = "1.23.2"
|
||||
hotpocket-common = {path = "../packages/common", develop = true}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import os
|
||||
|
||||
from invoke import task
|
||||
@@ -49,7 +48,7 @@ def isort(ctx, check=False, diff=False):
|
||||
|
||||
@task
|
||||
def eslint(ctx):
|
||||
ctx.run('npx eslint')
|
||||
ctx.run('yarn run eslint')
|
||||
|
||||
|
||||
@task
|
||||
@@ -89,12 +88,7 @@ def django_shell(ctx):
|
||||
def ci(ctx):
|
||||
ihazsuccess = True
|
||||
|
||||
ci_tasks = [
|
||||
test,
|
||||
flake8,
|
||||
functools.partial(isort, check=True),
|
||||
typecheck,
|
||||
]
|
||||
ci_tasks = [test, lint, typecheck]
|
||||
for ci_task in ci_tasks:
|
||||
try:
|
||||
ci_task(ctx)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from .access_token import AccessTokenFactory # noqa: F401,F403
|
||||
from .account import AccountFactory # noqa: F401,F403
|
||||
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import factory
|
||||
|
||||
from hotpocket_backend.apps.accounts.models import AccessToken
|
||||
|
||||
|
||||
def AccessTokenMetaFactory() -> dict:
|
||||
return {
|
||||
'platform': 'MacIntel',
|
||||
'version': '1987.10.03',
|
||||
}
|
||||
|
||||
|
||||
class AccessTokenFactory(factory.django.DjangoModelFactory):
|
||||
account_uuid = None
|
||||
key = factory.LazyFunction(lambda: str(uuid.uuid4()))
|
||||
origin = factory.LazyFunction(
|
||||
lambda: f'safari-web-extension//{uuid.uuid4()}',
|
||||
)
|
||||
meta = factory.LazyFunction(AccessTokenMetaFactory)
|
||||
|
||||
class Meta:
|
||||
model = AccessToken
|
||||
@@ -0,0 +1,2 @@
|
||||
from .access_token import * # noqa: F401,F403
|
||||
from .account import * # noqa: F401,F403
|
||||
@@ -0,0 +1,122 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
|
||||
from django.utils.timezone import now
|
||||
import pytest
|
||||
|
||||
from hotpocket_soa.dto.accounts import AccessTokenOut
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def access_token_factory(request: pytest.FixtureRequest):
|
||||
default_account = request.getfixturevalue('account')
|
||||
|
||||
def factory(account=None, **kwargs):
|
||||
from hotpocket_backend_testing.factories.accounts import (
|
||||
AccessTokenFactory,
|
||||
)
|
||||
|
||||
return AccessTokenFactory(
|
||||
account_uuid=(
|
||||
account.pk
|
||||
if account is not None
|
||||
else default_account.pk
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def access_token(access_token_factory):
|
||||
return access_token_factory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def access_token_out(access_token):
|
||||
return AccessTokenOut.model_validate(access_token, from_attributes=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deleted_access_token(access_token_factory):
|
||||
return access_token_factory(deleted_at=now())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deleted_access_token_out(deleted_access_token):
|
||||
return AccessTokenOut.model_validate(deleted_access_token, from_attributes=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_access_token(access_token_factory):
|
||||
return access_token_factory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_access_token_out(other_access_token):
|
||||
return AccessTokenOut.model_validate(other_access_token, from_attributes=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def inactive_account_access_token(access_token_factory, inactive_account):
|
||||
return access_token_factory(account=inactive_account)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def inactive_account_access_token_out(access_token):
|
||||
return AccessTokenOut.model_validate(
|
||||
inactive_account_access_token, from_attributes=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_account_access_token(access_token_factory, other_account):
|
||||
return access_token_factory(account=other_account)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_account_access_token_out(other_account_access_token):
|
||||
return AccessTokenOut.model_validate(
|
||||
other_account_access_token, from_attributes=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def browsable_access_tokens(access_token,
|
||||
other_access_token,
|
||||
other_account_access_token,
|
||||
):
|
||||
return [
|
||||
access_token,
|
||||
other_access_token,
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def browsable_access_token_outs(browsable_access_tokens):
|
||||
return [
|
||||
AccessTokenOut.model_validate(obj, from_attributes=True)
|
||||
for obj
|
||||
in browsable_access_tokens
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def paginatable_access_tokens(access_token_factory):
|
||||
result = [
|
||||
access_token_factory()
|
||||
for _ in range(0, 12)
|
||||
]
|
||||
|
||||
return result[::-1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def paginatable_access_token_outs(paginatable_access_tokens):
|
||||
return [
|
||||
AccessTokenOut.model_validate(obj, from_attributes=True)
|
||||
for obj
|
||||
in paginatable_access_tokens
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
from .access_tokens import AccessTokensTestingService # noqa: F401
|
||||
from .accounts import AccountsTestingService # noqa: F401
|
||||
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
from hotpocket_backend.apps.accounts.models import AccessToken
|
||||
|
||||
|
||||
class AccessTokensTestingService:
|
||||
def assert_created(self,
|
||||
*,
|
||||
key: str,
|
||||
account_uuid: uuid.UUID,
|
||||
origin: str,
|
||||
meta: dict,
|
||||
):
|
||||
access_token = AccessToken.objects.get(key=key)
|
||||
assert access_token.account_uuid == account_uuid
|
||||
assert access_token.origin == origin
|
||||
assert access_token.meta == meta
|
||||
|
||||
assert access_token.created_at is not None
|
||||
assert access_token.updated_at is not None
|
||||
|
||||
def assert_deleted(self, *, pk: uuid.UUID, reference: typing.Any = None):
|
||||
association = AccessToken.objects.get(pk=pk)
|
||||
assert association.deleted_at is not None
|
||||
|
||||
if reference is not None:
|
||||
assert association.updated_at > reference.updated_at
|
||||
|
||||
def assert_not_deleted(self, *, pk: uuid.UUID, reference: typing.Any = None):
|
||||
association = AccessToken.objects.get(pk=pk)
|
||||
assert association.deleted_at is None
|
||||
|
||||
if reference is not None:
|
||||
assert association.updated_at == reference.updated_at
|
||||
154
services/backend/tests/ui/views/accounts/apps/test_browse.py
Normal file
@@ -0,0 +1,154 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
|
||||
import http
|
||||
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
import pytest
|
||||
from pytest_django import asserts
|
||||
|
||||
|
||||
def assert_default_mode_response_context(response, access_tokens):
|
||||
expected_access_token_ids = list(sorted(
|
||||
[obj.pk for obj in access_tokens],
|
||||
reverse=True,
|
||||
))
|
||||
|
||||
assert len(response.context['access_tokens']) == 2
|
||||
assert response.context['access_tokens'][0].pk == expected_access_token_ids[0]
|
||||
assert response.context['access_tokens'][1].pk == expected_access_token_ids[1]
|
||||
assert response.context['before'] is None
|
||||
assert response.context['next_url'] is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok(browsable_access_token_outs,
|
||||
authenticated_client: Client,
|
||||
):
|
||||
# When
|
||||
result = authenticated_client.get(
|
||||
reverse('ui.accounts.apps.browse'),
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
asserts.assertTemplateUsed(result, 'ui/accounts/apps/browse.html')
|
||||
|
||||
assert_default_mode_response_context(result, browsable_access_token_outs)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok_htmx(browsable_access_token_outs,
|
||||
authenticated_client: Client,
|
||||
):
|
||||
# When
|
||||
result = authenticated_client.get(
|
||||
reverse('ui.accounts.apps.browse'),
|
||||
headers={
|
||||
'HX-Request': 'true',
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
asserts.assertTemplateNotUsed(result, 'ui/accounts/apps/browse.html')
|
||||
asserts.assertTemplateUsed(result, 'ui/accounts/partials/apps/apps.html')
|
||||
|
||||
assert_default_mode_response_context(result, browsable_access_token_outs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'query_before_index,expected_before_index,expected_length,first_index,last_index',
|
||||
[
|
||||
(None, 9, 10, 0, 9),
|
||||
(9, None, 2, 10, 11),
|
||||
],
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
def test_pagination(query_before_index,
|
||||
expected_before_index,
|
||||
expected_length,
|
||||
first_index,
|
||||
last_index,
|
||||
paginatable_access_token_outs,
|
||||
authenticated_client: Client,
|
||||
):
|
||||
# Given
|
||||
request_data = {}
|
||||
|
||||
if query_before_index is not None:
|
||||
request_data['before'] = str(
|
||||
paginatable_access_token_outs[query_before_index].pk,
|
||||
)
|
||||
|
||||
# When
|
||||
result = authenticated_client.get(
|
||||
reverse('ui.accounts.apps.browse'),
|
||||
data=request_data,
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
expected_before = None
|
||||
expected_next_url = None
|
||||
|
||||
if expected_before_index:
|
||||
expected_before = paginatable_access_token_outs[expected_before_index].pk
|
||||
expected_next_url = reverse(
|
||||
'ui.accounts.apps.browse',
|
||||
query=[
|
||||
('before', expected_before),
|
||||
('limit', 10),
|
||||
],
|
||||
)
|
||||
|
||||
assert len(result.context['access_tokens']) == expected_length
|
||||
assert result.context['access_tokens'][0].pk == paginatable_access_token_outs[first_index].pk
|
||||
assert result.context['access_tokens'][-1].pk == paginatable_access_token_outs[last_index].pk
|
||||
assert result.context['before'] == expected_before
|
||||
assert result.context['next_url'] == expected_next_url
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_inactive_account(inactive_account_client: Client):
|
||||
# When
|
||||
result = inactive_account_client.get(
|
||||
reverse('ui.accounts.apps.browse'),
|
||||
)
|
||||
|
||||
# Then
|
||||
asserts.assertRedirects(
|
||||
result,
|
||||
reverse(
|
||||
'ui.accounts.login',
|
||||
query=[
|
||||
('next', reverse('ui.accounts.apps.browse')),
|
||||
],
|
||||
),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anonymous(client: Client):
|
||||
# When
|
||||
result = client.get(
|
||||
reverse('ui.accounts.apps.browse'),
|
||||
)
|
||||
|
||||
# Then
|
||||
asserts.assertRedirects(
|
||||
result,
|
||||
reverse(
|
||||
'ui.accounts.login',
|
||||
query=[
|
||||
('next', reverse('ui.accounts.apps.browse')),
|
||||
],
|
||||
),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
172
services/backend/tests/ui/views/accounts/apps/test_delete.py
Normal file
@@ -0,0 +1,172 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
|
||||
import http
|
||||
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
import pytest
|
||||
from pytest_django import asserts
|
||||
|
||||
from hotpocket_backend_testing.services.accounts import (
|
||||
AccessTokensTestingService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok(authenticated_client: Client,
|
||||
access_token_out,
|
||||
):
|
||||
# When
|
||||
result = authenticated_client.post(
|
||||
reverse('ui.accounts.apps.delete', args=(access_token_out.pk,)),
|
||||
data={
|
||||
'canhazconfirm': 'hai',
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
asserts.assertRedirects(
|
||||
result,
|
||||
reverse('ui.accounts.apps.browse'),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
AccessTokensTestingService().assert_deleted(
|
||||
pk=access_token_out.pk, reference=access_token_out,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok_htmx(authenticated_client: Client,
|
||||
access_token_out,
|
||||
):
|
||||
# When
|
||||
result = authenticated_client.post(
|
||||
reverse('ui.accounts.apps.delete', args=(access_token_out.pk,)),
|
||||
headers={
|
||||
'HX-Request': 'true',
|
||||
},
|
||||
data={
|
||||
'canhazconfirm': 'hai',
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
expected_payload = {
|
||||
'status': 'ok',
|
||||
'result': True,
|
||||
}
|
||||
assert result.json() == expected_payload
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_invalid_all_missing(authenticated_client: Client,
|
||||
access_token_out,
|
||||
):
|
||||
# When
|
||||
result = authenticated_client.post(
|
||||
reverse('ui.accounts.apps.delete', args=(access_token_out.pk,)),
|
||||
data={
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
AccessTokensTestingService().assert_not_deleted(
|
||||
pk=access_token_out.pk, reference=access_token_out,
|
||||
)
|
||||
|
||||
assert 'canhazconfirm' in result.context['form'].errors
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_invalid_all_empty(authenticated_client: Client,
|
||||
access_token_out,
|
||||
):
|
||||
# When
|
||||
result = authenticated_client.post(
|
||||
reverse('ui.accounts.apps.delete', args=(access_token_out.pk,)),
|
||||
data={
|
||||
'canhazconfirm': '',
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
AccessTokensTestingService().assert_not_deleted(
|
||||
pk=access_token_out.pk, reference=access_token_out,
|
||||
)
|
||||
|
||||
assert 'canhazconfirm' in result.context['form'].errors
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_other_account_access_token(authenticated_client: Client,
|
||||
other_account_access_token_out,
|
||||
):
|
||||
# When
|
||||
result = authenticated_client.post(
|
||||
reverse('ui.accounts.apps.delete', args=(other_account_access_token_out.pk,)),
|
||||
data={
|
||||
'canhazconfirm': 'hai',
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_inactive_account(inactive_account_client: Client,
|
||||
access_token_out,
|
||||
):
|
||||
# When
|
||||
result = inactive_account_client.post(
|
||||
reverse('ui.accounts.apps.delete', args=(access_token_out.pk,)),
|
||||
data={
|
||||
'canhazconfirm': 'hai',
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
asserts.assertRedirects(
|
||||
result,
|
||||
reverse(
|
||||
'ui.accounts.login',
|
||||
query=[
|
||||
('next', reverse('ui.accounts.apps.delete', args=(access_token_out.pk,))),
|
||||
],
|
||||
),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anonymous(client: Client,
|
||||
access_token_out,
|
||||
):
|
||||
# When
|
||||
result = client.post(
|
||||
reverse('ui.accounts.apps.delete', args=(access_token_out.pk,)),
|
||||
data={
|
||||
'canhazconfirm': 'hai',
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
asserts.assertRedirects(
|
||||
result,
|
||||
reverse(
|
||||
'ui.accounts.login',
|
||||
query=[
|
||||
('next', reverse('ui.accounts.apps.delete', args=(access_token_out.pk,))),
|
||||
],
|
||||
),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
60
services/backend/tests/ui/views/accounts/apps/test_index.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
import pytest
|
||||
from pytest_django import asserts
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok(authenticated_client: Client):
|
||||
# When
|
||||
result = authenticated_client.get(
|
||||
reverse('ui.accounts.apps.index'),
|
||||
follow=False,
|
||||
)
|
||||
|
||||
# Then
|
||||
asserts.assertRedirects(
|
||||
result,
|
||||
reverse('ui.accounts.apps.browse'),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_inactive_account(inactive_account_client: Client):
|
||||
# When
|
||||
result = inactive_account_client.get(
|
||||
reverse('ui.accounts.apps.index'),
|
||||
)
|
||||
|
||||
# Then
|
||||
asserts.assertRedirects(
|
||||
result,
|
||||
reverse(
|
||||
'ui.accounts.login',
|
||||
query=[('next', reverse('ui.accounts.apps.index'))],
|
||||
),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anonymous(client: Client):
|
||||
# When
|
||||
result = client.get(
|
||||
reverse('ui.accounts.apps.index'),
|
||||
)
|
||||
|
||||
# Then
|
||||
asserts.assertRedirects(
|
||||
result,
|
||||
reverse(
|
||||
'ui.accounts.login',
|
||||
query=[('next', reverse('ui.accounts.apps.index'))],
|
||||
),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
|
||||
import http
|
||||
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
import pytest
|
||||
from pytest_django import asserts
|
||||
|
||||
from hotpocket_common.url import URL
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok(authenticated_client: Client):
|
||||
# When
|
||||
result = authenticated_client.get(
|
||||
reverse('ui.integrations.extension.authenticate'),
|
||||
follow=False,
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FOUND
|
||||
assert 'Location' in result.headers
|
||||
|
||||
redirect_url = URL(result.headers['Location'])
|
||||
assert redirect_url.raw_path == reverse('ui.integrations.extension.post_authenticate')
|
||||
assert 'auth_key' in redirect_url.query
|
||||
|
||||
assert 'extension_auth_key' in authenticated_client.session
|
||||
assert authenticated_client.session['extension_auth_key'] == redirect_url.query['auth_key'][0]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_inactive_account(inactive_account_client: Client):
|
||||
# When
|
||||
result = inactive_account_client.get(
|
||||
reverse('ui.integrations.extension.authenticate'),
|
||||
)
|
||||
|
||||
# Then
|
||||
asserts.assertRedirects(
|
||||
result,
|
||||
reverse(
|
||||
'ui.accounts.login',
|
||||
query=[('next', reverse('ui.integrations.extension.authenticate'))],
|
||||
),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anonymous(client: Client):
|
||||
# When
|
||||
result = client.get(
|
||||
reverse('ui.integrations.extension.authenticate'),
|
||||
)
|
||||
|
||||
# Then
|
||||
asserts.assertRedirects(
|
||||
result,
|
||||
reverse(
|
||||
'ui.accounts.login',
|
||||
query=[('next', reverse('ui.integrations.extension.authenticate'))],
|
||||
),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
|
||||
import http
|
||||
import uuid
|
||||
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
import pytest
|
||||
from pytest_django import asserts
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_key():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok(authenticated_client: Client, auth_key):
|
||||
# Given
|
||||
session = authenticated_client.session
|
||||
session['extension_auth_key'] = auth_key
|
||||
session.save()
|
||||
|
||||
# When
|
||||
result = authenticated_client.get(
|
||||
reverse('ui.integrations.extension.post_authenticate'),
|
||||
data={
|
||||
'auth_key': auth_key,
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
asserts.assertTemplateUsed(
|
||||
result, 'ui/integrations/extension/post_authenticate.html',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_auth_key_not_in_session(authenticated_client: Client, auth_key):
|
||||
# When
|
||||
result = authenticated_client.get(
|
||||
reverse('ui.integrations.extension.post_authenticate'),
|
||||
data={
|
||||
'auth_key': auth_key,
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_auth_key_not_request(authenticated_client: Client, auth_key):
|
||||
# Given
|
||||
session = authenticated_client.session
|
||||
session['extension_auth_key'] = auth_key
|
||||
session.save()
|
||||
|
||||
# When
|
||||
result = authenticated_client.get(
|
||||
reverse('ui.integrations.extension.post_authenticate'),
|
||||
data={
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_auth_key_mismatch(authenticated_client: Client, auth_key):
|
||||
# Given
|
||||
session = authenticated_client.session
|
||||
session['extension_auth_key'] = auth_key
|
||||
session.save()
|
||||
|
||||
# When
|
||||
result = authenticated_client.get(
|
||||
reverse('ui.integrations.extension.post_authenticate'),
|
||||
data={
|
||||
'auth_key': 'thisisntright',
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_inactive_account(inactive_account_client: Client):
|
||||
# When
|
||||
result = inactive_account_client.get(
|
||||
reverse('ui.integrations.extension.post_authenticate'),
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anonymous(client: Client):
|
||||
# When
|
||||
result = client.get(
|
||||
reverse('ui.integrations.extension.post_authenticate'),
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||
0
services/backend/tests/ui/views/rpc/__init__.py
Normal file
@@ -0,0 +1,154 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
|
||||
import http
|
||||
import uuid
|
||||
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
import pytest
|
||||
|
||||
from hotpocket_backend_testing.services.accounts import (
|
||||
AccessTokensTestingService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def origin():
|
||||
return f'safari-web-extension://{uuid.uuid4()}'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_key():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def meta():
|
||||
return {
|
||||
'platform': 'MacIntel',
|
||||
'version': '1987.10.03',
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def call(rpc_call_factory, auth_key, meta):
|
||||
return rpc_call_factory(
|
||||
'accounts.access_tokens.create',
|
||||
[auth_key, meta],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok(authenticated_client: Client,
|
||||
auth_key,
|
||||
call,
|
||||
origin,
|
||||
account,
|
||||
meta,
|
||||
):
|
||||
# Given
|
||||
session = authenticated_client.session
|
||||
session['extension_auth_key'] = auth_key
|
||||
session.save()
|
||||
|
||||
# When
|
||||
result = authenticated_client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
content_type='application/json',
|
||||
headers={
|
||||
'Origin': origin,
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
call_result = result.json()
|
||||
assert 'error' not in call_result
|
||||
|
||||
AccessTokensTestingService().assert_created(
|
||||
key=call_result['result'],
|
||||
account_uuid=account.pk,
|
||||
origin=origin,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
assert 'extension_auth_key' not in authenticated_client.session
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_auth_key_missing(authenticated_client: Client,
|
||||
call,
|
||||
origin,
|
||||
):
|
||||
# When
|
||||
result = authenticated_client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
content_type='application/json',
|
||||
headers={
|
||||
'Origin': origin,
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
call_result = result.json()
|
||||
assert 'error' in call_result
|
||||
assert call_result['error']['data'] == 'Auth key missing'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_auth_key_mismatch(authenticated_client: Client,
|
||||
call,
|
||||
origin,
|
||||
):
|
||||
# Given
|
||||
session = authenticated_client.session
|
||||
session['extension_auth_key'] = 'thisisntright'
|
||||
session.save()
|
||||
|
||||
# When
|
||||
result = authenticated_client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
content_type='application/json',
|
||||
headers={
|
||||
'Origin': origin,
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
call_result = result.json()
|
||||
assert 'error' in call_result
|
||||
assert call_result['error']['data'] == 'Auth key mismatch'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_inactive_account(inactive_account_client: Client, call):
|
||||
# When
|
||||
result = inactive_account_client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anonymous(client: Client, call):
|
||||
# When
|
||||
result = client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||
157
services/backend/tests/ui/views/rpc/accounts/auth/test_check.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
|
||||
import http
|
||||
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def call(rpc_call_factory):
|
||||
return rpc_call_factory(
|
||||
'accounts.auth.check',
|
||||
[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok_session_auth(authenticated_client: Client,
|
||||
call,
|
||||
):
|
||||
# When
|
||||
result = authenticated_client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
call_result = result.json()
|
||||
assert 'error' not in call_result
|
||||
assert call_result['result'] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_session_auth_inactive_account(inactive_account_client: Client,
|
||||
call,
|
||||
):
|
||||
# When
|
||||
result = inactive_account_client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok_access_token_auth(client: Client,
|
||||
call,
|
||||
access_token_out,
|
||||
):
|
||||
# When
|
||||
result = client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
content_type='application/json',
|
||||
headers={
|
||||
'Authorization': f'Bearer {access_token_out.key}',
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
call_result = result.json()
|
||||
assert 'error' not in call_result
|
||||
assert call_result['result'] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_access_token_auth_not_bearer(client: Client,
|
||||
call,
|
||||
access_token_out,
|
||||
):
|
||||
# When
|
||||
result = client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
headers={
|
||||
'Authorization': f'thisisntright {access_token_out.key}',
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_access_token_auth_invalid_access_token(client: Client,
|
||||
call,
|
||||
null_uuid,
|
||||
):
|
||||
# When
|
||||
result = client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
headers={
|
||||
'Authorization': f'Bearer {null_uuid}',
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_access_token_auth_deleted_access_token(client: Client,
|
||||
call,
|
||||
deleted_access_token,
|
||||
):
|
||||
# When
|
||||
result = client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
headers={
|
||||
'Authorization': f'Bearer {deleted_access_token.key}',
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_access_token_auth_inactive_account(client: Client,
|
||||
call,
|
||||
inactive_account_access_token,
|
||||
):
|
||||
# When
|
||||
result = client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
headers={
|
||||
'Authorization': f'Bearer {inactive_account_access_token.key}',
|
||||
},
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anonymous(client: Client, call):
|
||||
# When
|
||||
result = client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||
287
services/backend/tests/ui/views/rpc/saves/test_create.py
Normal file
@@ -0,0 +1,287 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
|
||||
import http
|
||||
from unittest import mock
|
||||
import uuid
|
||||
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
import pytest
|
||||
import pytest_mock
|
||||
|
||||
from hotpocket_backend_testing.services.saves import (
|
||||
AssociationsTestingService,
|
||||
SaveProcessorTestingService,
|
||||
SavesTestingService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_saves_process_save_task_apply_async(mocker: pytest_mock.MockerFixture,
|
||||
async_result,
|
||||
) -> mock.Mock:
|
||||
return SaveProcessorTestingService().mock_process_save_task_apply_async(
|
||||
mocker=mocker, async_result=async_result,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def call(rpc_call_factory):
|
||||
return rpc_call_factory(
|
||||
'saves.create',
|
||||
['https://www.ziomek.dog/'],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok(authenticated_client: Client,
|
||||
call,
|
||||
account,
|
||||
mock_saves_process_save_task_apply_async: mock.Mock,
|
||||
):
|
||||
# When
|
||||
result = authenticated_client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
call_result = result.json()
|
||||
assert 'error' not in call_result
|
||||
|
||||
save_pk = uuid.UUID(call_result['result']['target_uuid'])
|
||||
association_pk = uuid.UUID(call_result['result']['id'])
|
||||
|
||||
AssociationsTestingService().assert_created(
|
||||
pk=association_pk,
|
||||
account_uuid=account.pk,
|
||||
target_uuid=save_pk,
|
||||
)
|
||||
|
||||
SavesTestingService().assert_created(
|
||||
pk=save_pk,
|
||||
account_uuid=account.pk,
|
||||
url=call['params'][0],
|
||||
is_netloc_banned=False,
|
||||
)
|
||||
|
||||
mock_saves_process_save_task_apply_async.assert_called_once_with(
|
||||
kwargs={
|
||||
'pk': save_pk,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok_netloc_banned(authenticated_client: Client,
|
||||
call,
|
||||
account,
|
||||
mock_saves_process_save_task_apply_async: mock.Mock,
|
||||
):
|
||||
# Given
|
||||
call['params'][0] = 'https://youtube.com/'
|
||||
|
||||
# When
|
||||
result = authenticated_client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
call_result = result.json()
|
||||
assert 'error' not in call_result
|
||||
|
||||
save_pk = uuid.UUID(call_result['result']['target_uuid'])
|
||||
|
||||
SavesTestingService().assert_created(
|
||||
pk=save_pk,
|
||||
account_uuid=account.pk,
|
||||
url=call['params'][0],
|
||||
is_netloc_banned=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok_resuse_save(save_out,
|
||||
authenticated_client: Client,
|
||||
call,
|
||||
account,
|
||||
mock_saves_process_save_task_apply_async: mock.Mock,
|
||||
):
|
||||
# Given
|
||||
call['params'][0] = save_out.url
|
||||
|
||||
# When
|
||||
result = authenticated_client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
call_result = result.json()
|
||||
assert 'error' not in call_result
|
||||
|
||||
save_pk = uuid.UUID(call_result['result']['target_uuid'])
|
||||
association_pk = uuid.UUID(call_result['result']['id'])
|
||||
|
||||
AssociationsTestingService().assert_created(
|
||||
pk=association_pk,
|
||||
account_uuid=account.pk,
|
||||
target_uuid=save_pk,
|
||||
)
|
||||
|
||||
SavesTestingService().assert_reused(
|
||||
pk=save_pk,
|
||||
reference=save_out,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok_resuse_association(association_out,
|
||||
save_out,
|
||||
authenticated_client: Client,
|
||||
call,
|
||||
account,
|
||||
mock_saves_process_save_task_apply_async: mock.Mock,
|
||||
):
|
||||
# Given
|
||||
call['params'][0] = save_out.url
|
||||
|
||||
# When
|
||||
result = authenticated_client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
call_result = result.json()
|
||||
assert 'error' not in call_result
|
||||
|
||||
association_pk = uuid.UUID(call_result['result']['id'])
|
||||
|
||||
AssociationsTestingService().assert_reused(
|
||||
pk=association_pk,
|
||||
reference=association_out,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok_reuse_other_account_save(other_account_save_out,
|
||||
authenticated_client: Client,
|
||||
call,
|
||||
account,
|
||||
mock_saves_process_save_task_apply_async: mock.Mock,
|
||||
):
|
||||
# Given
|
||||
call['params'][0] = other_account_save_out.url
|
||||
|
||||
# When
|
||||
result = authenticated_client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
call_result = result.json()
|
||||
assert 'error' not in call_result
|
||||
|
||||
save_pk = uuid.UUID(call_result['result']['target_uuid'])
|
||||
association_pk = uuid.UUID(call_result['result']['id'])
|
||||
|
||||
AssociationsTestingService().assert_created(
|
||||
pk=association_pk,
|
||||
account_uuid=account.pk,
|
||||
target_uuid=save_pk,
|
||||
)
|
||||
|
||||
SavesTestingService().assert_reused(
|
||||
pk=save_pk,
|
||||
reference=other_account_save_out,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ok_dont_process_reused_processed_save(processed_save_out,
|
||||
authenticated_client: Client,
|
||||
call,
|
||||
account,
|
||||
mock_saves_process_save_task_apply_async: mock.Mock,
|
||||
):
|
||||
# Given
|
||||
call['params'][0] = processed_save_out.url
|
||||
|
||||
# When
|
||||
_ = authenticated_client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# Then
|
||||
mock_saves_process_save_task_apply_async.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_empty_url(authenticated_client: Client,
|
||||
call,
|
||||
account,
|
||||
mock_saves_process_save_task_apply_async: mock.Mock,
|
||||
):
|
||||
# Given
|
||||
call['params'][0] = ''
|
||||
|
||||
# When
|
||||
result = authenticated_client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.OK
|
||||
|
||||
call_result = result.json()
|
||||
assert 'error' in call_result
|
||||
|
||||
assert call_result['error']['data']['url'] == ['blank']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_inactive_account(inactive_account_client: Client, call):
|
||||
# When
|
||||
result = inactive_account_client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anonymous(client: Client, call):
|
||||
# When
|
||||
result = client.post(
|
||||
reverse('ui.rpc'),
|
||||
data=call,
|
||||
)
|
||||
|
||||
# Then
|
||||
assert result.status_code == http.HTTPStatus.FORBIDDEN
|
||||