# -*- 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, }, )