• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

liqd / roots / 22072536647

16 Feb 2026 05:41PM UTC coverage: 42.093%. First build
22072536647

Pull #59

github

Pull Request #59: apps/summerization: Integrate Document Summary into Workflow

51 of 314 new or added lines in 7 files covered. (16.24%)

3564 of 8467 relevant lines covered (42.09%)

0.42 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

52.8
/apps/projects/views.py
1
import itertools
1✔
2
import logging
1✔
3

1✔
4
from django.contrib import messages
5
from django.contrib.auth import get_user_model
1✔
6
from django.contrib.sites.shortcuts import get_current_site
1✔
7
from django.http import HttpResponse
1✔
8
from django.shortcuts import get_object_or_404
1✔
9
from django.shortcuts import redirect
1✔
10
from django.shortcuts import render
1✔
11
from django.template.loader import render_to_string
1✔
12
from django.urls import reverse_lazy
1✔
13
from django.utils import timezone
1✔
14
from django.utils.decorators import method_decorator
1✔
15
from django.utils.functional import cached_property
1✔
16
from django.utils.translation import get_language
1✔
17
from django.utils.translation import gettext_lazy as _
1✔
18
from django.utils.translation import ngettext
1✔
19
from django.views import View
1✔
20
from django.views import generic
21
from django.views.decorators.csrf import csrf_exempt
1✔
22
from rules.contrib.views import LoginRequiredMixin
1✔
23
from rules.contrib.views import PermissionRequiredMixin
1✔
24
from sentry_sdk import capture_exception
1✔
25

1✔
26
from adhocracy4.dashboard import mixins as a4dashboard_mixins
1✔
27
from adhocracy4.dashboard import signals as a4dashboard_signals
1✔
28
from adhocracy4.dashboard.components.forms.views import ProjectComponentFormView
1✔
29
from adhocracy4.modules import models as module_models
1✔
30
from adhocracy4.projects import models as project_models
1✔
31
from adhocracy4.projects.mixins import DisplayProjectOrModuleMixin
1✔
32
from adhocracy4.projects.mixins import PhaseDispatchMixin
1✔
33
from adhocracy4.projects.mixins import ProjectMixin
34
from adhocracy4.projects.mixins import ProjectModuleDispatchMixin
1✔
35
from apps.projects.models import ProjectInsight
1✔
36
from apps.summarization.models import ProjectSummary
1✔
37
from apps.summarization.models import SummaryFeedback
1✔
38

1✔
39
from . import dashboard
1✔
40
from . import forms
41
from . import models
1✔
42
from .utils import generate_project_summary
43

1✔
44
User = get_user_model()
45

46
logger = logging.getLogger(__name__)
1✔
47

1✔
48

1✔
49
class ParticipantInviteDetailView(generic.DetailView):
1✔
50
    model = models.ParticipantInvite
51
    slug_field = "token"
1✔
52
    slug_url_kwarg = "invite_token"
×
53

×
54
    def dispatch(self, request, invite_token, *args, **kwargs):
55
        if request.user.is_authenticated:
56
            return redirect(
57
                "project-participant-invite-update",
58
                organisation_slug=self.get_object().project.organisation.slug,
59
                invite_token=invite_token,
×
60
            )
61
        else:
62
            return super().dispatch(request, *args, **kwargs)
1✔
63

1✔
64

1✔
65
class ParticipantInviteUpdateView(LoginRequiredMixin, generic.UpdateView):
1✔
66
    model = models.ParticipantInvite
1✔
67
    form_class = forms.ParticipantInviteForm
68
    slug_field = "token"
1✔
69
    slug_url_kwarg = "invite_token"
×
70

×
71
    def form_valid(self, form):
×
72
        if form.is_accepted():
73
            form.instance.accept(self.request.user)
×
74
            return redirect(form.instance.project.get_absolute_url())
×
75
        else:
76
            form.instance.reject()
77
            return redirect("/")
1✔
78

1✔
79

1✔
80
class ModeratorInviteDetailView(generic.DetailView):
1✔
81
    model = models.ModeratorInvite
82
    slug_field = "token"
1✔
83
    slug_url_kwarg = "invite_token"
×
84

×
85
    def dispatch(self, request, invite_token, *args, **kwargs):
86
        if request.user.is_authenticated:
87
            return redirect(
88
                "project-moderator-invite-update",
89
                organisation_slug=self.get_object().project.organisation.slug,
90
                invite_token=invite_token,
×
91
            )
92
        else:
93
            return super().dispatch(request, *args, **kwargs)
1✔
94

1✔
95

1✔
96
class ModeratorInviteUpdateView(LoginRequiredMixin, generic.UpdateView):
1✔
97
    model = models.ModeratorInvite
1✔
98
    form_class = forms.ModeratorInviteForm
99
    slug_field = "token"
1✔
100
    slug_url_kwarg = "invite_token"
×
101

×
102
    def form_valid(self, form):
×
103
        if form.is_accepted():
104
            form.instance.accept(self.request.user)
×
105
            return redirect(form.instance.project.get_absolute_url())
×
106
        else:
107
            form.instance.reject()
108
            return redirect("/")
1✔
109

110

111
class AbstractProjectUserInviteListView(
112
    ProjectMixin,
113
    a4dashboard_mixins.DashboardBaseMixin,
114
    a4dashboard_mixins.DashboardComponentMixin,
115
    generic.base.TemplateResponseMixin,
116
    generic.edit.FormMixin,
117
    generic.detail.SingleObjectMixin,
1✔
118
    generic.edit.ProcessFormView,
1✔
119
):
120
    form_class = forms.InviteUsersFromEmailForm
1✔
121
    invite_model = None
×
122

×
123
    def get(self, request, *args, **kwargs):
124
        self.object = self.get_object()
1✔
125
        return super().get(request, *args, **kwargs)
×
126

×
127
    def post(self, request, *args, **kwargs):
×
128
        self.object = self.get_object()
×
129
        if "submit_action" in request.POST:
×
130
            if request.POST["submit_action"] == "remove_user":
×
131
                pk = int(request.POST["user_pk"])
×
132
                user = get_object_or_404(User, pk=pk)
×
133
                related_users = getattr(self.object, self.related_users_field)
×
134
                related_users.remove(user)
×
135
                messages.success(request, self.success_message_removal)
×
136
            elif request.POST["submit_action"] == "remove_invite":
×
137
                pk = int(request.POST["invite_pk"])
×
138
                if self.invite_model.objects.filter(pk=pk).exists():
×
139
                    invite = self.invite_model.objects.get(pk=pk)
140
                    invite.delete()
×
141
                    messages.success(request, _("Invitation succesfully removed."))
142
                else:
143
                    messages.info(
144
                        request, _("Invitation was already accepted or removed.")
×
145
                    )
146

×
147
            response = redirect(self.get_success_url())
148
        else:
×
149
            response = super().post(request, *args, **kwargs)
×
150

151
        self._send_component_updated_signal()
1✔
152
        return response
×
153

×
154
    def filter_existing(self, emails):
×
155
        related_users = getattr(self.object, self.related_users_field)
×
156
        related_emails = [u.email for u in related_users.all()]
×
157
        existing = []
×
158
        filtered_emails = []
×
159
        for email in emails:
160
            if email in related_emails:
×
161
                existing.append(email)
×
162
            else:
163
                filtered_emails.append(email)
1✔
164
        return filtered_emails, existing
×
165

×
166
    def filter_pending(self, emails):
×
167
        pending = []
×
168
        filtered_emails = []
169
        for email in emails:
170
            if self.invite_model.objects.filter(
×
171
                email=email, project=self.project
172
            ).exists():
×
173
                pending.append(email)
×
174
            else:
175
                filtered_emails.append(email)
1✔
176
        return filtered_emails, pending
×
177

178
    def form_valid(self, form):
179
        emails = list(
180
            set(
181
                itertools.chain(
182
                    form.cleaned_data["add_users"],
183
                    form.cleaned_data["add_users_upload"],
184
                )
185
            )
×
186
        )
×
187

×
188
        emails, existing = self.filter_existing(emails)
189
        if existing:
190
            messages.error(
191
                self.request,
192
                _("Following users already accepted an invitation: ")
193
                + ", ".join(existing),
×
194
            )
×
195

×
196
        emails, pending = self.filter_pending(emails)
197
        if pending:
198
            messages.error(
199
                self.request,
200
                _("Following users are already invited: ") + ", ".join(pending),
×
201
            )
×
202

203
        for email in emails:
204
            self.invite_model.objects.invite(
205
                creator=self.request.user,
206
                project=self.project,
207
                email=email,
208
                site=get_current_site(self.request),
×
209
            )
210

211
        messages.success(
212
            self.request,
213
            ngettext(
214
                self.success_message[0], self.success_message[1], len(emails)
215
            ).format(len(emails)),
×
216
        )
217

1✔
218
        return redirect(self.get_success_url())
×
219

×
220
    def get_form_kwargs(self):
×
221
        kwargs = super().get_form_kwargs()
222
        kwargs["labels"] = (self.add_user_field_label, self.add_user_upload_field_label)
1✔
223
        return kwargs
×
224

225
    def _send_component_updated_signal(self):
226
        a4dashboard_signals.project_component_updated.send(
227
            sender=self.component.__class__,
228
            project=self.project,
229
            component=self.component,
230
            user=self.request.user,
231
        )
1✔
232

1✔
233

1✔
234
class DashboardProjectModeratorsView(AbstractProjectUserInviteListView):
1✔
235
    model = project_models.Project
1✔
236
    slug_url_kwarg = "project_slug"
1✔
237
    template_name = "a4_candy_projects/project_moderators.html"
238
    permission_required = "a4projects.change_project"
1✔
239
    menu_item = "project"
1✔
240

1✔
241
    related_users_field = "moderators"
1✔
242
    add_user_field_label = _("Invite moderators via email")
1✔
243
    add_user_upload_field_label = _("Invite moderators via file upload")
244
    success_message = (_("{} moderator invited."), _("{} moderators invited."))
1✔
245
    success_message_removal = _("Moderator successfully removed.")
246

1✔
247
    invite_model = models.ModeratorInvite
×
248

249
    def get_permission_object(self):
250
        return self.project
1✔
251

1✔
252

1✔
253
class DashboardProjectParticipantsView(AbstractProjectUserInviteListView):
1✔
254
    model = project_models.Project
1✔
255
    slug_url_kwarg = "project_slug"
1✔
256
    template_name = "a4_candy_projects/project_participants.html"
257
    permission_required = "a4projects.change_project"
1✔
258
    menu_item = "project"
1✔
259

1✔
260
    related_users_field = "participants"
1✔
261
    add_user_field_label = _("Invite users via email")
1✔
262
    add_user_upload_field_label = _("Invite users via file upload")
263
    success_message = (_("{} participant invited."), _("{} participants invited."))
1✔
264
    success_message_removal = _("Participant successfully removed.")
265

1✔
266
    invite_model = models.ParticipantInvite
×
267

268
    def get_permission_object(self):
269
        return self.project
1✔
270

1✔
271

1✔
272
class ProjectDeleteView(PermissionRequiredMixin, generic.DeleteView):
1✔
273
    model = project_models.Project
1✔
274
    permission_required = "a4projects.delete_project"
275
    http_method_names = ["post"]
1✔
276
    success_message = _("Project '%(name)s' was deleted successfully.")
×
277

278
    def get_success_url(self):
279
        return reverse_lazy(
280
            "a4dashboard:project-list",
281
            kwargs={"organisation_slug": self.get_object().organisation.slug},
1✔
282
        )
×
283

×
284
    def form_valid(self, request, *args, **kwargs):
×
285
        obj = self.get_object()
286
        messages.success(self.request, self.success_message % obj.__dict__)
287
        return super().form_valid(request, *args, **kwargs)
1✔
288

289

290
class ProjectDetailView(
291
    PermissionRequiredMixin,
292
    ProjectModuleDispatchMixin,
1✔
293
    DisplayProjectOrModuleMixin,
1✔
294
):
1✔
295
    model = models.Project
296
    permission_required = "a4projects.view_project"
1✔
297
    template_name = "a4_candy_projects/project_detail.html"
×
298

299
    def get_permission_object(self):
1✔
300
        return self.project
1✔
301

×
302
    @cached_property
303
    def is_project_view(self):
1✔
304
        return self.get_current_modules()
1✔
305

×
306
    @property
307
    def raise_exception(self):
308
        return self.request.user.is_authenticated
1✔
309

1✔
310

×
311
class ProjectResultInsightComponentFormView(ProjectComponentFormView):
×
312
    def get_context_data(self, **kwargs):
313
        context = super().get_context_data(**kwargs)
314
        ProjectInsight.update_context(
315
            project=self.project, context=context, dashboard=True
×
316
        )
×
317

318
        if self.request.POST:
319
            context["insight_form"] = dashboard.ProjectInsightForm(
320
                data=self.request.POST, instance=self.project.insight
×
321
            )
322
        else:
323
            context["insight_form"] = dashboard.ProjectInsightForm(
324
                instance=self.project.insight
×
325
            )
326

1✔
327
        return context
×
328

×
329
    def form_valid(self, form):
×
330
        context = self.get_context_data()
×
331
        insight_form = context["insight_form"]
×
332
        if insight_form.is_valid():
333
            insight_form.save()
334
        return super().form_valid(form)
1✔
335

1✔
336

1✔
337
class ModuleDetailView(PermissionRequiredMixin, PhaseDispatchMixin):
1✔
338
    model = module_models.Module
339
    permission_required = "a4projects.view_project"
1✔
340
    slug_url_kwarg = "module_slug"
1✔
341

×
342
    @cached_property
343
    def project(self):
1✔
344
        return self.module.project
1✔
345

×
346
    @cached_property
347
    def module(self):
1✔
348
        return self.get_object()
×
349

350
    def get_permission_object(self):
1✔
351
        return self.project
352

×
353
    def get_context_data(self, **kwargs):
×
354
        """Append project and module to the template context."""
×
355
        if "project" not in kwargs:
×
356
            kwargs["project"] = self.project
×
357
        if "module" not in kwargs:
358
            kwargs["module"] = self.module
359
        return super().get_context_data(**kwargs)
1✔
360

1✔
361

1✔
362
class ProjectGenerateSummaryView(PermissionRequiredMixin, generic.DetailView):
1✔
363
    model = models.Project
364
    slug_url_kwarg = "slug"
1✔
365
    permission_required = "a4projects.view_project"
×
366

367
    def get_permission_object(self):
1✔
368
        return self.get_object()
×
369

×
370
    def _get_user_feedback(self, summary, request):
371
        """Get user feedback for summary."""
1✔
372
        if not summary:
×
373
            return None
374

×
375
        if request.user.is_authenticated:
×
376
            fb = SummaryFeedback.objects.filter(
377
                summary=summary, user=request.user
NEW
378
            ).first()
×
379
            return fb.feedback if fb else None
380
        elif request.session.session_key:
381
            fb = SummaryFeedback.objects.filter(
NEW
382
                summary=summary, session_key=request.session.session_key
×
NEW
383
            ).first()
×
NEW
384
            return fb.feedback if fb else None
×
NEW
385
        return None
×
386

387
    def _format_summary_date(self, timestamp, language_code):
NEW
388
        """Format the summary date based on the current language."""
×
389
        if not timestamp:
390
            return None
391

392
        local_ts = timezone.localtime(timestamp)
NEW
393

×
NEW
394
        if language_code == "de":
×
395
            # German format: "1. April 2026"
396
            return local_ts.strftime("%-d. %B %Y")
397
        else:
NEW
398
            # English format: "April 1, 2026"
×
399
            return local_ts.strftime("%B %-d, %Y")
400

×
401
    def get(self, request, *args, **kwargs):
402
        project = self.get_object()
×
403
        logger.info(
×
404
            f"ProjectGenerateSummaryView: Starting summary for project {project.id} ({project.slug})"
405
        )
406
        try:
407
            # For the button endpoint we never force regeneration; we always show
408
            # the latest stored summary and let Celery handle periodic updates.
409
            response = generate_project_summary(
410
                project, request=request, allow_regeneration=False
411
            )
×
412
            try:
413
                summary = ProjectSummary.objects.filter(project=project).latest(
414
                    "created_at"
415
                )
416
            except ProjectSummary.DoesNotExist:
417
                logger.debug(
418
                    "No summary found in DB, but response available - using response directly"
419
                )
×
420
                summary = None
421

×
422
            user_feedback = self._get_user_feedback(summary, request)
×
423

424
            # Get current language code from the request
425
            language_code = get_language()
426

427
            summary_timestamp = None
428
            summary_date_str = None
429
            if summary:
430
                ts = summary.last_checked_at or summary.created_at
431
                summary_timestamp = timezone.localtime(ts)
432
                summary_date_str = self._format_summary_date(
433
                    summary_timestamp, language_code
434
                )
435

436
            html = render_to_string(
437
                "a4_candy_projects/_summary_fragment.html",
438
                {
439
                    "response": response,
440
                    "project": project,
441
                    "summary_id": summary.id if summary else None,
442
                    "summary_created_at": summary.created_at if summary else None,
443
                    "summary_timestamp": summary_timestamp,
444
                    "summary_date_str": summary_date_str,
445
                    "user_feedback": user_feedback,
446
                    "show_debug": False,
447
                    "raw": response.model_dump_json(),
448
                },
449
            )
450
            logger.info(
451
                f"ProjectGenerateSummaryView: Summary completed successfully for project {project.id}"
452
            )
453

454
            return HttpResponse(html)
455

456
        except Exception as e:
457
            logger.error(
458
                f"Failed to generate summary for project {project.id} ({project.slug}): {str(e)}",
459
                exc_info=True,
460
            )
461
            capture_exception(e)
462
            html = render_to_string(
463
                "a4_candy_projects/_summary_error.html", {"project": project}
464
            )
465
            return HttpResponse(html)
466

467

468
class ProjectGenerateSummaryTestView(
469
    LoginRequiredMixin, PermissionRequiredMixin, generic.DetailView
470
):
471
    """
472
    Test view to manually trigger project summary generation.
473
    On each request, a new AI summary is generated when no valid cache exists
474
    (allow_regeneration=True).
475
    Only available to logged-in users with the view_project permission.
476
    """
477

478
    model = models.Project
479
    slug_url_kwarg = "slug"
480
    permission_required = "a4projects.view_project"
481

482
    def get_permission_object(self):
483
        return self.get_object()
484

485
    def get(self, request, *args, **kwargs):
486
        project = self.get_object()
487
        logger.info(
488
            "ProjectGenerateSummaryTestView: Forcing summary generation for project %s (%s)",
489
            project.id,
490
            project.slug,
491
        )
492
        # Trigger only; no HTML rendering or explicit error handling.
493
        generate_project_summary(project, request=request, allow_regeneration=True)
494
        return HttpResponse(
495
            f"AI summary generation triggered for project {project.id} ({project.slug})."
496
        )
497

498

499
@method_decorator(csrf_exempt, name="dispatch")
500
class SummaryFeedbackView(View):
501
    def post(self, request, organisation_slug, slug):
502
        project = get_object_or_404(models.Project, slug=slug)
503
        summary_id = request.POST.get("summary_id")
504
        feedback = request.POST.get("feedback")
505

506
        if feedback not in ["positive", "negative"] or not summary_id:
507
            return HttpResponse("Invalid request", status=400)
508

509
        summary = get_object_or_404(ProjectSummary, id=summary_id, project=project)
510

511
        user = request.user if request.user.is_authenticated else None
512

513
        # Ensure a session exists so anonymous users get a stable session_key.
514
        if not request.session.session_key:
515
            request.session.save()
516
        session_key = request.session.session_key
517

518
        # Look up existing feedback for this summary and user/session.
519
        if user:
520
            existing_qs = SummaryFeedback.objects.filter(summary=summary, user=user)
521
        elif session_key:
522
            existing_qs = SummaryFeedback.objects.filter(
523
                summary=summary, session_key=session_key
524
            )
525
        else:
526
            existing_qs = SummaryFeedback.objects.none()
527

528
        existing_fb = existing_qs.first()
529

530
        if existing_fb and existing_fb.feedback == feedback:
531
            # Same button clicked again -> retract feedback.
532
            existing_qs.delete()
533
            user_feedback = None
534
        else:
535
            # Replace previous feedback (if any) with the new value.
536
            existing_qs.delete()
537
            SummaryFeedback.objects.create(
538
                summary=summary,
539
                user=user,
540
                feedback=feedback,
541
                session_key=session_key,
542
            )
543
            user_feedback = feedback
544

545
        return render(
546
            request,
547
            "a4_candy_projects/_feedback_icons.html",
548
            {
549
                "user_feedback": user_feedback,
550
                "summary_id": summary_id,
551
                "project": project,
552
            },
553
        )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc