Files
hotpocket/services/backend/hotpocket_backend/apps/ui/views/associations.py
2026-03-12 16:26:54 +00:00

421 lines
12 KiB
Python

# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
import uuid
from django.contrib import messages
from django.contrib.auth.views import redirect_to_login
import django.db
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import redirect, render
from django.templatetags.static import static
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 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:
return redirect_to_login(
reverse('ui.associations.view', args=(pk,)),
)
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
share_url = request.build_absolute_uri(
reverse(
'ui.associations.view',
args=(association.pk,),
query=[
('share', 'true'),
],
),
)
og_card_url = request.build_absolute_uri(
static('ui/img/og-card.png'),
)
return render(
request,
'ui/associations/view.html',
{
'association': association,
'show_controls': show_controls,
'share_url': share_url,
'is_share': is_share,
'og_card_url': (
og_card_url
if is_share is True
else None
),
},
)
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,
},
)