• 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 json
1✔
3
import logging
1✔
4

5
from django.contrib import messages
1✔
6
from django.contrib.auth import get_user_model
1✔
7
from django.contrib.sites.shortcuts import get_current_site
1✔
8
from django.http import HttpResponse
1✔
9
from django.shortcuts import get_object_or_404
1✔
10
from django.shortcuts import redirect
1✔
11
from django.shortcuts import render
1✔
12
from django.template.loader import render_to_string
1✔
13
from django.urls import reverse_lazy
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 gettext_lazy as _
1✔
17
from django.utils.translation import ngettext
1✔
18
from django.views import View
1✔
19
from django.views import generic
1✔
20
from django.views.decorators.csrf import csrf_exempt
21
from rules.contrib.views import LoginRequiredMixin
1✔
22
from rules.contrib.views import PermissionRequiredMixin
1✔
23
from sentry_sdk import capture_exception
1✔
24

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

1✔
40
from . import dashboard
41
from . import forms
1✔
42
from . import models
43
from .export_utils import collect_document_attachments
1✔
44
from .export_utils import generate_full_export
45
from .export_utils import integrate_document_summaries
46

1✔
47
User = get_user_model()
1✔
48

1✔
49
logger = logging.getLogger(__name__)
1✔
50

51

1✔
52
class ParticipantInviteDetailView(generic.DetailView):
×
53
    model = models.ParticipantInvite
×
54
    slug_field = "token"
55
    slug_url_kwarg = "invite_token"
56

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

1✔
67

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

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

82

1✔
83
class ModeratorInviteDetailView(generic.DetailView):
×
84
    model = models.ModeratorInvite
×
85
    slug_field = "token"
86
    slug_url_kwarg = "invite_token"
87

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

1✔
98

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

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

113

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

×
126
    def get(self, request, *args, **kwargs):
×
127
        self.object = self.get_object()
×
128
        return super().get(request, *args, **kwargs)
×
129

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

×
150
            response = redirect(self.get_success_url())
151
        else:
1✔
152
            response = super().post(request, *args, **kwargs)
×
153

×
154
        self._send_component_updated_signal()
×
155
        return response
×
156

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

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

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

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

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

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

214
        messages.success(
215
            self.request,
×
216
            ngettext(
217
                self.success_message[0], self.success_message[1], len(emails)
1✔
218
            ).format(len(emails)),
×
219
        )
×
220

×
221
        return redirect(self.get_success_url())
222

1✔
223
    def get_form_kwargs(self):
×
224
        kwargs = super().get_form_kwargs()
225
        kwargs["labels"] = (self.add_user_field_label, self.add_user_upload_field_label)
226
        return kwargs
227

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

1✔
236

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

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

250
    invite_model = models.ModeratorInvite
1✔
251

1✔
252
    def get_permission_object(self):
1✔
253
        return self.project
1✔
254

1✔
255

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

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

269
    invite_model = models.ParticipantInvite
1✔
270

1✔
271
    def get_permission_object(self):
1✔
272
        return self.project
1✔
273

1✔
274

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

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

287
    def form_valid(self, request, *args, **kwargs):
1✔
288
        obj = self.get_object()
289
        messages.success(self.request, self.success_message % obj.__dict__)
290
        return super().form_valid(request, *args, **kwargs)
291

292

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

×
302
    def get_permission_object(self):
303
        return self.project
1✔
304

1✔
305
    @cached_property
×
306
    def is_project_view(self):
307
        return self.get_current_modules()
308

1✔
309
    @property
1✔
310
    def raise_exception(self):
×
311
        return self.request.user.is_authenticated
×
312

313

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

×
321
        if self.request.POST:
322
            context["insight_form"] = dashboard.ProjectInsightForm(
323
                data=self.request.POST, instance=self.project.insight
324
            )
×
325
        else:
326
            context["insight_form"] = dashboard.ProjectInsightForm(
1✔
327
                instance=self.project.insight
×
328
            )
×
329

×
330
        return context
×
331

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

339

1✔
340
class ModuleDetailView(PermissionRequiredMixin, PhaseDispatchMixin):
1✔
341
    model = module_models.Module
×
342
    permission_required = "a4projects.view_project"
343
    slug_url_kwarg = "module_slug"
1✔
344

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

×
349
    @cached_property
350
    def module(self):
1✔
351
        return self.get_object()
352

×
353
    def get_permission_object(self):
×
354
        return self.project
×
355

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

364

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

×
370
    def get_permission_object(self):
371
        return self.get_object()
1✔
372

×
373
    def _generate_export_data(self, project):
374
        export_data = generate_full_export(project)
×
375
        return export_data
×
376

377
    def _process_documents(self, export_data, request, project):
NEW
378
        """Process and summarize document attachments."""
×
379
        documents_dict, handle_to_source = collect_document_attachments(
380
            export_data, request
381
        )
NEW
382

×
NEW
383
        if documents_dict:
×
NEW
384
            try:
×
NEW
385
                service = AIService()
×
386
                document_response = service.request_vision_dict(
387
                    documents_dict=documents_dict
NEW
388
                )
×
389
                integrate_document_summaries(
390
                    export_data,
391
                    document_response.documents,
392
                    handle_to_source,
NEW
393
                )
×
NEW
394
            except Exception as e:
×
395
                logger.error(
396
                    f"Failed to summarize documents for project {project.slug}: {str(e)}",
397
                    exc_info=True,
NEW
398
                )
×
399
                capture_exception(e)
400

×
401
    def _get_user_feedback(self, summary, request):
402
        """Get user feedback for summary."""
×
403
        if not summary:
×
404
            return None
405

406
        if request.user.is_authenticated:
407
            fb = SummaryFeedback.objects.filter(
408
                summary=summary, user=request.user
409
            ).first()
410
            return fb.feedback if fb else None
411
        elif request.session.session_key:
×
412
            fb = SummaryFeedback.objects.filter(
413
                summary=summary, session_key=request.session.session_key
414
            ).first()
415
            return fb.feedback if fb else None
416
        return None
417

418
    def get(self, request, *args, **kwargs):
419
        project = self.get_object()
×
420
        logger.info(
421
            f"ProjectGenerateSummaryView: Starting summary for project {project.id} ({project.slug})"
×
422
        )
×
423

424
        try:
425
            export_data = self._generate_export_data(project)
426
            self._process_documents(export_data, request, project)
427

428
            json_text = json.dumps(export_data, indent=2)
429
            logger.debug(
430
                f"ProjectGenerateSummaryView: Export data generated ({len(json_text)} chars), calling project_summarize"
431
            )
432

433
            service = AIService()
434
            response = service.project_summarize(
435
                project=project,
436
                text=json_text,
437
                result_type=ProjectSummaryResponse,
438
                is_rate_limit=True,
439
            )
440

441
            try:
442
                summary = ProjectSummary.objects.filter(project=project).latest(
443
                    "created_at"
444
                )
445
            except ProjectSummary.DoesNotExist:
446
                logger.debug(
447
                    "No summary found in DB, but response available - using response directly"
448
                )
449
                summary = None
450

451
            user_feedback = self._get_user_feedback(summary, request)
452

453
            html = render_to_string(
454
                "a4_candy_projects/_summary_fragment.html",
455
                {
456
                    "response": response,
457
                    "project": project,
458
                    "summary_id": summary.id if summary else None,
459
                    "user_feedback": user_feedback,
460
                },
461
            )
462
            logger.info(
463
                f"ProjectGenerateSummaryView: Summary completed successfully for project {project.id}"
464
            )
465

466
            return HttpResponse(html)
467

468
        except Exception as e:
469
            logger.error(
470
                f"Failed to generate summary for project {project.id} ({project.slug}): {str(e)}",
471
                exc_info=True,
472
            )
473
            capture_exception(e)
474
            html = render_to_string("a4_candy_projects/_summary_error.html")
475
            return HttpResponse(html)
476

477

478
@method_decorator(csrf_exempt, name="dispatch")
479
class SummaryFeedbackView(View):
480
    def post(self, request, organisation_slug, slug):
481
        project = get_object_or_404(models.Project, slug=slug)
482
        summary_id = request.POST.get("summary_id")
483
        feedback = request.POST.get("feedback")
484

485
        if feedback not in ["positive", "negative"] or not summary_id:
486
            return HttpResponse("Invalid request", status=400)
487

488
        summary = get_object_or_404(ProjectSummary, id=summary_id, project=project)
489

490
        user = request.user if request.user.is_authenticated else None
491
        session_key = request.session.session_key
492

493
        # Delete previous feedback
494
        if user:
495
            SummaryFeedback.objects.filter(summary=summary, user=user).delete()
496
        elif session_key:
497
            SummaryFeedback.objects.filter(
498
                summary=summary, session_key=session_key
499
            ).delete()
500

501
        # Create new feedback
502
        SummaryFeedback.objects.create(
503
            summary=summary, user=user, feedback=feedback, session_key=session_key
504
        )
505

506
        return render(
507
            request,
508
            "a4_candy_projects/_feedback_icons.html",
509
            {"user_feedback": feedback, "summary_id": summary_id, "project": project},
510
        )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc