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

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

View File

@@ -0,0 +1,8 @@
from . import accounts # noqa: F401
from . import associations # noqa: F401
from . import errors # noqa: F401
from . import imports # noqa: F401
from . import index # noqa: F401
from . import integrations # noqa: F401
from . import meta # noqa: F401
from . import saves # noqa: F401

View File

@@ -0,0 +1,247 @@
# -*- coding: utf-8 -*-
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.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 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 (
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'))
class BaseSettingsView(AccountRequiredMixin, FormView):
template_name = 'ui/accounts/settings.html'
@property
def is_federated(self) -> bool:
return all((
self.request.user.is_anonymous is False,
self.request.user.has_usable_password() is False,
))
def get_context_data(self, **kwargs) -> dict:
result = super().get_context_data(**kwargs)
result.update({
'is_federated': self.is_federated,
})
return result
class ProfileView(BaseSettingsView):
def get_form_class(self) -> type[ProfileForm]:
if self.is_federated is True:
return FederatedProfileForm
return ProfileForm
def get_initial(self) -> dict:
result = super().get_initial()
result.update({
'username': self.request.user.username,
'first_name': self.request.user.first_name,
'last_name': self.request.user.last_name,
'email': self.request.user.email,
})
return result
def get_context_data(self, **kwargs) -> dict:
result = super().get_context_data(**kwargs)
result.update({
'title': _('Profile'),
'active_tab': 'profile',
})
return result
def form_valid(self, form: ProfileForm) -> HttpResponse:
assert self.is_federated is False, (
'Refuse to save profile of a federated account: '
f'account=`{self.request.user}`'
)
with django.db.transaction.atomic():
self.request.user.first_name = form.cleaned_data['first_name']
self.request.user.last_name = form.cleaned_data['last_name']
self.request.user.email = form.cleaned_data['email']
self.request.user.save()
messages.add_message(
self.request,
messages.SUCCESS,
message=_('Your profile has been been updated!'),
)
return super().form_valid(form)
def get_success_url(self) -> str:
return reverse('ui.accounts.settings.profile')
class PasswordView(BaseSettingsView):
def get_form_class(self) -> type[PasswordForm]:
if self.is_federated is True:
return FederatedPasswordForm
return PasswordForm
def get_form(self,
form_class: type[PasswordForm] | None = None,
) -> PasswordForm:
form_class = form_class or self.get_form_class()
return form_class(self.request.user, **self.get_form_kwargs())
def get_context_data(self, **kwargs) -> dict:
result = super().get_context_data(**kwargs)
result.update({
'title': _('Password'),
'active_tab': 'password',
})
return result
def form_valid(self, form: PasswordForm) -> HttpResponse:
assert self.is_federated is False, (
'Refuse to change password of a federated account: '
f'account=`{self.request.user}`'
)
with django.db.transaction.atomic():
form.set_password_and_save(
self.request.user,
password_field_name='new_password1',
)
messages.add_message(
self.request,
messages.SUCCESS,
message=_('Your password has been changed!'),
)
return super().form_valid(form)
def get_success_url(self) -> str:
return reverse('ui.accounts.settings.password')
class SettingsView(BaseSettingsView):
form_class = SettingsForm
def get_context_data(self, **kwargs) -> dict:
result = super().get_context_data(**kwargs)
result.update({
'title': _('Settings'),
'active_tab': 'settings',
'is_federated': False,
})
return result
def get_initial(self) -> dict:
return self.request.user.settings
def form_valid(self, form: PasswordForm) -> HttpResponse:
with django.db.transaction.atomic():
self.request.user.raw_settings = form.cleaned_data
self.request.user.save()
messages.add_message(
self.request,
messages.SUCCESS,
message=_('Your settings have been updated!'),
)
return super().form_valid(form)
def get_success_url(self) -> str:
return reverse('ui.accounts.settings.settings')

View File

@@ -0,0 +1,395 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
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, View
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 StarUnstarAssociationViewMode
from hotpocket_backend.apps.ui.dto.saves import BrowseParams
from hotpocket_backend.apps.ui.forms.associations import (
ArchiveForm,
DeleteForm,
EditForm,
RefreshForm,
)
from hotpocket_backend.apps.ui.services import UIAssociationsService
from hotpocket_common.constants import NULL_UUID, AssociationsSearchMode
from hotpocket_soa.dto.associations import (
AssociationOut,
AssociationsQuery,
AssociationUpdateIn,
AssociationWithTargetOut,
)
from hotpocket_soa.services import AssociationsService, SaveProcessorService
LOGGER = logging.getLogger(__name__)
class AssociationMixin:
ALLOW_ARCHIVED_ASSOCIATION = False
def get_association(self,
with_target: bool = False,
) -> AssociationOut | AssociationWithTargetOut:
attribute = '_instance'
if with_target is True:
attribute = '_instance_with_target'
if hasattr(self, attribute) is False:
setattr(
self,
attribute,
UIAssociationsService().get_or_404(
account_uuid=self.request.user.pk, # type: ignore[attr-defined]
pk=self.kwargs['pk'], # type: ignore[attr-defined]
with_target=with_target,
allow_archived=self.ALLOW_ARCHIVED_ASSOCIATION,
),
)
return getattr(self, attribute)
class DetailView(AssociationMixin, AccountRequiredMixin, FormView):
def get_context_data(self, **kwargs) -> dict:
result = super().get_context_data(**kwargs)
result.update({
'association': self.get_association(),
})
return result
class ConfirmationView(DetailView):
def get_initial(self) -> dict:
result = super().get_initial()
association: AssociationWithTargetOut = self.get_association( # type: ignore[assignment]
with_target=True,
)
result.update({
'canhazconfirm': 'hai',
'title': association.title,
'url': association.target.url,
})
return result
def get_success_url(self) -> str:
return reverse('ui.associations.browse')
@account_required
def index(request: HttpRequest) -> HttpResponse:
return redirect(reverse('ui.associations.browse'))
@account_required
def browse(request: HttpRequest) -> HttpResponse:
params = BrowseParams.from_request(request=request)
associations = AssociationsService().search(
query=AssociationsQuery.model_validate(dict(
account_uuid=request.user.pk,
before=params.before,
search=params.search or None,
mode=params.mode,
)),
limit=params.limit,
)
before: uuid.UUID | None = None
if len(associations) == params.limit:
before = associations[-1].pk
next_url: str | None = None
if before is not None:
next_url = reverse('ui.associations.browse', query=[
('before', before),
('search', params.search or ''),
('limit', params.limit),
('mode', params.mode.value),
])
context = {
'associations': associations,
'params': params,
'before': before,
'next_url': next_url,
}
if request.htmx:
response = render(
request,
'ui/associations/partials/associations.html',
context,
)
return trigger_client_event(
response,
'HotPocket:BrowseSavesView:updateLoadMoreButton',
{'next_url': next_url},
after='swap',
)
title: str
match params.mode:
case AssociationsSearchMode.STARRED:
title = _('Starred')
case AssociationsSearchMode.ARCHIVED:
title = _('Archived')
case _:
title = 'HotPocket'
context['title'] = title
return render(
request,
'ui/associations/browse.html',
context,
)
def view(request: HttpRequest, pk: uuid.UUID) -> HttpResponse:
account_uuid: uuid.UUID | None = None
allow_archived = False
is_share = request.GET.get('share', None) is not None
if request.user.is_anonymous is True or request.user.is_active is False:
if is_share is True:
account_uuid = None
else:
account_uuid = NULL_UUID
else:
if is_share is False:
account_uuid = request.user.pk
allow_archived = True
association = UIAssociationsService().get_or_404(
account_uuid=account_uuid,
pk=pk,
with_target=True,
allow_archived=allow_archived,
)
show_controls = True
if association.archived_at is not None:
show_controls = show_controls and False
if request.user.is_anonymous is True:
show_controls = show_controls and False
else:
if is_share is True:
show_controls = show_controls and False
return render(
request,
'ui/associations/view.html',
{
'association': association,
'show_controls': show_controls,
},
)
class EditView(DetailView):
template_name = 'ui/associations/edit.html'
form_class = EditForm
def get_initial(self) -> dict:
result = super().get_initial()
association = self.get_association(with_target=True)
result.update({
'url': association.target.url, # type: ignore[attr-defined]
'target_title': association.get_title(),
'target_description': association.get_description(),
})
return result
def form_valid(self, form: EditForm) -> HttpResponse:
with django.db.transaction.atomic():
updated_association = AssociationsService().update( # noqa: F841
association=self.get_association(),
update=AssociationUpdateIn.model_validate(form.cleaned_data),
)
messages.add_message(
self.request,
messages.SUCCESS,
_('The save has been updated!'),
)
return super().form_valid(form)
def get_success_url(self) -> str:
return reverse(
'ui.associations.view',
args=(self.get_association(with_target=True).pk,),
)
class StarUnstarView(AssociationMixin, AccountRequiredMixin, View):
mode = StarUnstarAssociationViewMode.STAR
def get(self, request: HttpRequest, pk: uuid.UUID) -> HttpResponse:
with django.db.transaction.atomic():
association = self.get_association()
match self.mode:
case StarUnstarAssociationViewMode.STAR:
_ = AssociationsService().star(association=association)
case StarUnstarAssociationViewMode.UNSTAR:
_ = AssociationsService().unstar(association=association)
case _:
raise ValueError(f'Unknown mode: {self.mode.value}')
if request.htmx:
return render(
request,
'ui/associations/partials/association_card.html',
{
'association': self.get_association(with_target=True),
},
)
return redirect('ui.associations.browse')
@account_required
def post_save(request: HttpRequest, pk: uuid.UUID) -> HttpResponse:
association = UIAssociationsService().get_or_404(
account_uuid=request.user.pk,
pk=pk,
with_target=True,
)
return render(
request,
'ui/associations/post_save.html',
{
'association': association,
},
)
class RefreshView(ConfirmationView):
template_name = 'ui/associations/refresh.html'
form_class = RefreshForm
def form_valid(self, form: RefreshForm) -> HttpResponse:
result = SaveProcessorService().schedule_process_save(
save=self.get_association(with_target=True).target, # type: ignore[attr-defined]
)
if self.request.htmx:
response = JsonResponse({
'status': 'ok',
'result': result.id,
})
htmx_messages.add_htmx_message(
request=self.request,
response=response,
level=htmx_messages.SUCCESS,
message=_('The save has been refreshed!'),
)
return response
else:
messages.add_message(
self.request,
messages.SUCCESS,
_('The save has been refreshed!'),
)
return super().form_valid(form)
class ArchiveView(ConfirmationView):
template_name = 'ui/associations/archive.html'
form_class = ArchiveForm
def form_valid(self, form: ArchiveView) -> HttpResponse:
with django.db.transaction.atomic():
result = AssociationsService().archive(
association=self.get_association(),
)
if self.request.htmx:
return JsonResponse({
'status': 'ok',
'result': result,
})
messages.add_message(
self.request,
messages.SUCCESS,
_('The save has been archived.'),
)
return super().form_valid(form)
class DeleteView(ConfirmationView):
template_name = 'ui/associations/delete.html'
form_class = DeleteForm
ALLOW_ARCHIVED_ASSOCIATION = True
def form_valid(self, form: DeleteForm) -> HttpResponse:
with django.db.transaction.atomic():
result = AssociationsService().delete(
association=self.get_association(),
)
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 save has been deleted.'),
)
return response
messages.add_message(
self.request,
messages.SUCCESS,
_('The save has been deleted.'),
)
return super().form_valid(form)
def get_success_url(self) -> str:
return reverse(
'ui.associations.browse',
query={
'mode': AssociationsSearchMode.ARCHIVED.value,
},
)

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
from django.http import (
HttpRequest,
HttpResponseBadRequest,
HttpResponseForbidden,
HttpResponseNotFound,
HttpResponseServerError,
)
from django.template.loader import render_to_string
from django.views.decorators.csrf import requires_csrf_token
from django.views.defaults import (
ERROR_400_TEMPLATE_NAME,
ERROR_403_TEMPLATE_NAME,
ERROR_404_TEMPLATE_NAME,
ERROR_500_TEMPLATE_NAME,
)
LOGGER = logging.getLogger(__name__)
@requires_csrf_token
def page_not_found(request: HttpRequest,
exception: Exception,
template_name: str = ERROR_404_TEMPLATE_NAME,
) -> HttpResponseNotFound:
if exception:
LOGGER.error('Exception: %s', exception, exc_info=exception)
return HttpResponseNotFound(render_to_string(
'ui/errors/page_not_found.html',
context={},
request=request,
))
@requires_csrf_token
def server_error(request: HttpRequest,
template_name: str = ERROR_500_TEMPLATE_NAME,
) -> HttpResponseServerError:
return HttpResponseServerError(render_to_string(
'ui/errors/internal_server_error.html',
context={},
request=request,
))
@requires_csrf_token
def bad_request(request: HttpRequest,
exception: Exception,
template_name: str = ERROR_400_TEMPLATE_NAME,
) -> HttpResponseBadRequest:
if exception:
LOGGER.error('Exception: %s', exception, exc_info=exception)
return HttpResponseBadRequest(render_to_string(
'ui/errors/internal_server_error.html',
context={},
request=request,
))
@requires_csrf_token
def permission_denied(request: HttpRequest,
exception: Exception,
template_name: str = ERROR_403_TEMPLATE_NAME,
) -> HttpResponseForbidden:
if exception:
LOGGER.error('Exception: %s', exception, exc_info=exception)
return HttpResponseForbidden(render_to_string(
'ui/errors/internal_server_error.html',
context={},
request=request,
))
def csrf_failure(request: HttpRequest, reason: str = ''):
return HttpResponseBadRequest(render_to_string(
'ui/errors/internal_server_error.html',
context={},
request=request,
))

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib import messages
from django.http import HttpResponse
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView
from hotpocket_backend.apps.accounts.mixins import AccountRequiredMixin
from hotpocket_backend.apps.core.conf import settings
from hotpocket_backend.apps.ui.forms.imports import PocketImportForm
from hotpocket_backend.apps.ui.services import UIImportsService
class PocketImportView(AccountRequiredMixin, FormView):
template_name = 'ui/imports/pocket.html'
form_class = PocketImportForm
def form_valid(self, form: PocketImportForm) -> HttpResponse:
csv_filename = '{user_pk}-{filename}'.format(
user_pk=self.request.user.pk,
filename=form.cleaned_data['csv'].name,
)
csv_path = settings.UPLOADS_PATH / csv_filename
with open(csv_path, 'wb') as csv_file:
for chunk in form.cleaned_data['csv']:
csv_file.write(chunk)
result = UIImportsService().schedule_import_from_pocket(
account_uuid=self.request.user.pk,
csv_path=str(csv_path),
)
if result.ready() is True:
messages.add_message(
self.request,
messages.SUCCESS,
_('Import complete!'),
)
else:
messages.add_message(
self.request,
messages.INFO,
_('The import has been scheduled.'),
)
return super().form_valid(form)
def get_success_url(self) -> str:
return reverse('ui.associations.index')

View File

@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse
@login_required
def index(request: HttpRequest) -> HttpResponse:
return redirect(reverse('ui.associations.browse'))

View File

@@ -0,0 +1,2 @@
from . import android # noqa: F401
from . import ios # noqa: F401

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import http
import logging
import django.db
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from hotpocket_backend.apps.accounts.decorators import account_required
from hotpocket_backend.apps.ui.services.workflows import CreateSaveWorkflow
LOGGER = logging.getLogger(__name__)
@csrf_exempt
@account_required
def share_sheet(request: HttpRequest) -> HttpResponse:
LOGGER.debug('POST=`%s`', request.POST)
try:
with django.db.transaction.atomic():
assert request.user.is_anonymous is False, 'Login required'
assert 'text' in request.POST, 'Bad request: Missing `text`'
url = request.POST['text'].split('\n')[0].strip()
assert url != '', 'Bad request: Empty `text`'
return CreateSaveWorkflow().run(
request=request,
account=request.user,
url=url,
force_post_save=True,
)
except Exception as exception:
LOGGER.error(
'Unhandled exception: %s',
exception,
exc_info=exception,
)
return render(
request,
'ui/errors/internal_server_error.html',
# Returning 200 here to avoid browsers showing generic error pages.
status=http.HTTPStatus.OK,
)

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import http
import logging
import django.db
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from hotpocket_backend.apps.accounts.decorators import account_required
from hotpocket_backend.apps.ui.services.workflows import CreateSaveWorkflow
LOGGER = logging.getLogger(__name__)
@account_required
def shortcut(request: HttpRequest) -> HttpResponse:
LOGGER.debug('GET=`%s`', request.GET)
try:
with django.db.transaction.atomic():
assert request.user.is_anonymous is False, 'Login required'
assert 'url' in request.GET, 'Bad request: Missing `url`'
url = request.GET['url'].split('\n')[0].strip()
assert url != '', 'Bad request: Empty `url`'
return CreateSaveWorkflow().run(
request=request,
account=request.user,
url=url,
force_post_save=True,
)
except Exception as exception:
LOGGER.error(
'Unhandled exception: %s',
exception,
exc_info=exception,
)
return render(
request,
'ui/errors/internal_server_error.html',
# Returning 200 here to avoid browsers showing generic error pages.
status=http.HTTPStatus.OK,
)

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.http import HttpRequest, JsonResponse
from django.templatetags.static import static
from django.urls import reverse
from hotpocket_backend.apps.core.conf import settings
def manifest_json(request: HttpRequest) -> JsonResponse:
result = {
'name': settings.SITE_TITLE,
'short_name': settings.SITE_SHORT_TITLE,
'start_url': reverse('ui.associations.browse'),
'display': 'standalone',
'background_color': '#212529',
'theme_color': '#2b3035',
'icons': [
{
'src': static('ui/img/icon-192.png'),
'sizes': '192x192',
'type': 'image/png',
},
{
'src': static('ui/img/icon-512.png'),
'sizes': '512x512',
'type': 'image/png',
},
],
'share_target': {
'action': reverse('ui.integrations.android.share_sheet'),
'method': 'POST',
'enctype': 'multipart/form-data',
'params': {
'title': 'title',
'text': 'text',
'url': 'url',
'files': [],
},
},
}
return JsonResponse(result)

View File

@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import http
import logging
import django.db
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.urls import reverse
from django.views.generic import FormView
from django_htmx.http import trigger_client_event
from hotpocket_backend.apps.accounts.mixins import AccountRequiredMixin
from hotpocket_backend.apps.ui.forms.saves import CreateForm
from hotpocket_backend.apps.ui.services import UISavesService
from hotpocket_backend.apps.ui.services.workflows import CreateSaveWorkflow
LOGGER = logging.getLogger(__name__)
class CreateView(AccountRequiredMixin, FormView):
template_name = 'ui/saves/create.html'
form_class = CreateForm
def form_valid(self, form: CreateForm) -> HttpResponse:
with django.db.transaction.atomic():
return CreateSaveWorkflow().run(
request=self.request,
account=self.request.user,
url=form.cleaned_data['url'],
)
def get_success_url(self) -> str:
return reverse('ui.associations.browse')
def embed(request: HttpRequest) -> HttpResponse:
try:
assert request.method == http.HTTPMethod.POST, (
f'405 Method Not Allowed: `{request.method}`'
)
assert 'pk' in request.POST, (
'400 Bad Request: `pk` missing'
)
assert request.htmx, (
'400 Bad Reqquest: Not an HTMX request'
)
except AssertionError as exception:
LOGGER.error(
'Unable to render save embed: pk=`pk`: %s',
exception,
exc_info=exception,
)
return HttpResponse(
b'NOPE',
status=http.HTTPStatus.BAD_REQUEST,
content_type='text/plain;charset=utf-8',
)
save = UISavesService().get_or_404(pk=request.POST['pk'])
response = render(
request,
'ui/saves/partials/embed.html',
{
'save': save,
},
)
trigger_client_event(
response,
'HotPocket:ViewAssociationView:loadEmbed',
{},
after='swap',
)
return response