Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Admin des Besoins): Demande de modification ou clôture d'un besoin #1596

Open
wants to merge 24 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5741fe0
Add two attributes in Tender :
chloend Dec 12, 2024
77915ea
Create 'add_log_entry' method in Tender
chloend Dec 12, 2024
151faab
Create 'reset_modification_request' method in Tender
chloend Dec 12, 2024
a23fdf8
Test 'add_log_entry' Tender method
chloend Dec 13, 2024
f02c725
Test 'reset_modification_request' Tender method
chloend Dec 13, 2024
01d578a
Add two Tender methods in Tender creation :
chloend Dec 13, 2024
b54f445
Add rejected status
chloend Dec 13, 2024
df44ee0
Tests for 'email_sent_for_modification' and 'changes_information' in …
chloend Dec 13, 2024
c784b94
Create email tasks and templates :
chloend Dec 15, 2024
8e0e7d4
Add 'email_sent_for_modification' to tender admin
chloend Dec 15, 2024
937c3d6
Add 'changes_information' to tender admin
chloend Dec 15, 2024
a495bc2
Add 'email_sent_for_modification' and 'changes_information' in clean …
chloend Dec 15, 2024
d2cfd36
Admin : send emails if tender needs modification or is rejected
chloend Dec 15, 2024
9b646c9
Data persistance if 'email_sent_for_modification' and 'changes_inform…
chloend Dec 15, 2024
69d541b
'email_sent_for_modification' is readonly while tender is not published
chloend Dec 15, 2024
70b31d6
Add 'get_object_update_url' to retrieve updated urls from any app
chloend Dec 21, 2024
1b97dda
Test 'get_object_update_url' function
chloend Dec 21, 2024
6b5dd6d
Replace email domain for non prod envs and tests
chloend Dec 21, 2024
ac6c5ac
Use 'get_object_update_url' instead of 'get_object_share_url'
chloend Dec 21, 2024
4e205e9
Add an additional message in the mail if changes_information is not e…
chloend Dec 21, 2024
69b0d9c
Store the date of email sent in tender.logs
chloend Dec 21, 2024
3a8dcf9
Create 'update_tender_status_to_rejected' command with its tests
chloend Dec 24, 2024
302ba09
Create 'tenders_update_status_to_rejected' script and add it to cron
chloend Dec 24, 2024
9a3db2b
final commit
chloend Jan 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion clevercloud/cron.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@
"35 8 * * * $ROOT/clevercloud/tenders_send_siae_transactioned_question_emails.sh",
"0 9 * * * $ROOT/clevercloud/tenders_send_siae_contacted_reminder_emails.sh",
"10 9 * * * $ROOT/clevercloud/tenders_send_siae_interested_reminder_emails.sh",
"*/5 8-15 * * 1-5 $ROOT/clevercloud/tenders_send_validated.sh"
"*/5 8-15 * * 1-5 $ROOT/clevercloud/tenders_send_validated.sh",
"0 23 * * * $ROOT/clevercloud/tenders_update_status_to_rejected.sh"
]
22 changes: 22 additions & 0 deletions clevercloud/tenders_update_status_to_rejected.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/bin/bash -l

# Update Tenders' status to rejected if no changes whitin 10 days since modification request

# Do not run if this env var is not set:
if [[ -z "$CRON_TENDER_UPDATE_STATUS_TO_REJECTED_ENABLED" ]]; then
echo "CRON_TENDER_UPDATE_STATUS_TO_REJECTED_ENABLED not set. Exiting..."
exit 0
fi

# About clever cloud cronjobs:
# https://developers.clever-cloud.com/doc/administrate/cron/

if [[ "$INSTANCE_NUMBER" != "0" ]]; then
echo "Instance number is ${INSTANCE_NUMBER}. Stop here."
exit 0
fi

# $APP_HOME is set by default by clever cloud.
cd $APP_HOME

django-admin update_tender_status_to_rejected
12 changes: 12 additions & 0 deletions lemarche/api/tenders/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,18 @@ def test_create_contact_call_has_user_buyer_attributes(self, mock_create_contact
if sectors.exists():
attributes["TYPE_VERTICALE_ACHETEUR"] = sectors.first().name

def test_reset_modification_request(self):
"""Test 'reset_modification_request' method to check tender fields updates"""
extra_data = {"source": "TALLY"}
_, tender, _ = self.setup_mock_user_and_tender_creation(
title="Test tally", user=self.user_buyer, extra_data=extra_data
)
tender.reset_modification_request()
tender.save()

self.assertEqual(tender.status, tender_constants.STATUS_PUBLISHED)
self.assertEqual(tender.email_sent_for_modification, False)

def test_create_tender_with_different_contact_data(self):
tender_data = TENDER_JSON.copy()
tender_data["title"] = "Test tally contact"
Expand Down
4 changes: 4 additions & 0 deletions lemarche/api/tenders/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ def perform_create(self, serializer: TenderSerializer):
source=tender_source,
import_raw_object=self.request.data,
)
# Check before adding logs or resetting modification request
if tender.status == tender_constants.STATUS_PUBLISHED:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Je ne suis pas sûr de l'utilité de ce if vu que status est affecté juste au dessus.

tender.reset_modification_request()

add_to_contact_list(user=user, type="signup", source=user_source, tender=tender)


Expand Down
2 changes: 1 addition & 1 deletion lemarche/conversations/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class Meta:
title = factory.Faker("name", locale="fr_FR")
sender_first_name = factory.Faker("name", locale="fr_FR")
sender_last_name = factory.Faker("name", locale="fr_FR")
sender_email = factory.Sequence("email{0}@beta.gouv.fr".format)
sender_email = factory.Sequence("email{0}@inclusion.gouv.fr".format)
siae = factory.SubFactory(SiaeFactory)
initial_body_message = factory.Faker("name", locale="fr_FR")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from django.db import migrations


def create_template(apps, schema_editor):
TemplateTransactional = apps.get_model("conversations", "TemplateTransactional")
TemplateTransactional.objects.create(
name="Dépôt de besoin : auteur : modifications requises",
code="TENDERS_AUTHOR_MODIFICATION_REQUEST",
description="Envoyé à l'auteur du besoin pour lui demander de le modifier ou de prendre rendez-vous avec les admins",
)
TemplateTransactional.objects.create(
name="Dépôt de besoin : auteur : dépôt de besoin rejeté",
code="TENDERS_AUTHOR_REJECT_MESSAGE",
description="Envoyé à l'auteur du besoin pour l'informer du rejet de son dépôt de besoin",
)


def delete_template(apps, schema_editor):
TemplateTransactional = apps.get_model("conversations", "TemplateTransactional")
TemplateTransactional.objects.filter(code="TENDERS_AUTHOR_MODIFICATION_REQUEST").delete()
TemplateTransactional.objects.filter(code="TENDERS_AUTHOR_REJECT_MESSAGE").delete()


class Migration(migrations.Migration):
dependencies = [
("conversations", "0018_conversation_is_anonymized"),
]

operations = [
migrations.RunPython(create_template, reverse_code=delete_template),
]
2 changes: 1 addition & 1 deletion lemarche/siaes/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class Meta:
post_code = factory.Faker("postalcode")
department = factory.fuzzy.FuzzyChoice([key for (key, value) in Siae.DEPARTMENT_CHOICES])
region = factory.fuzzy.FuzzyChoice([key for (key, value) in Siae.REGION_CHOICES])
contact_email = factory.Sequence("siae_contact_email{0}@beta.gouv.fr".format)
contact_email = factory.Sequence("siae_contact_email{0}@inclusion.gouv.fr".format)
contact_first_name = factory.Faker("name", locale="fr_FR")
contact_last_name = factory.Faker("name", locale="fr_FR")

Expand Down
5 changes: 3 additions & 2 deletions lemarche/templates/tenders/admin_change_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@
{% block after_related_objects %}
{{ block.super }}
{% if original %}
{% if not original.validated_at %}
{% if not original.validated_at and original.status == "PUBLISHED"%}
<div class="submit-row">
<input type="submit" class="button" name="_calculate_tender" value="Sauvegarder et chercher les structures correspondantes" />
<input type="submit" name="_send_modification_request" value="Envoyer une demande de modification"/>
</div>
{% endif %}
<div class="submit-row" style="display:block">
Expand All @@ -54,7 +55,7 @@
<p><i>Date idéale de début des prestations dépassée ({{ original.start_working_date }})</i></p>
{% endif %}
{% endif %}
{% else %}
{% elif original.status == "PUBLISHED" %}
<p><i>L'envoi des besoins 'validés' se fait toutes les 5 minutes, du Lundi au Vendredi, entre 9h et 17h</i></p>
<input type="submit" value="Valider (sauvegarder) et envoyer aux structures 🚀" data-recipient="siaes" data-title="{{ original.title }}" style="margin-right: 5px"/>
<input type="submit" value="Valider (sauvegarder) et envoyer aux partenaires 🚀" data-recipient="partners" data-title="{{ original.title }}"/>
Expand Down
46 changes: 44 additions & 2 deletions lemarche/tenders/admin.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import logging

from ckeditor.widgets import CKEditorWidget
from django import forms
from django.contrib import admin
Expand All @@ -24,7 +26,14 @@
from lemarche.utils.admin.admin_site import admin_site
from lemarche.utils.apis import api_brevo
from lemarche.utils.fields import ChoiceArrayField, pretty_print_readonly_jsonfield
from lemarche.www.tenders.tasks import restart_send_tender_task
from lemarche.www.tenders.tasks import (
restart_send_tender_task,
send_tender_author_modification_request,
send_tender_author_reject_message,
)


logger = logging.getLogger(__name__)


class KindFilter(MultiChoice):
Expand Down Expand Up @@ -278,6 +287,7 @@ def clean(self):
"""
cleaned_data = super().clean()
distance_location = cleaned_data.get("distance_location")

if distance_location:
location = cleaned_data.get("location")
if not location:
Expand All @@ -302,13 +312,15 @@ class TenderAdmin(FieldsetsInlineMixin, admin.ModelAdmin):
"start_working_date_in_list",
"siae_count_annotated_with_link_in_list",
"siae_detail_contact_click_count_annotated_with_link_in_list",
"status",
"is_validated_or_sent",
"is_followed_by_us",
]

list_filter = [
AmountCustomFilter,
("kind", KindFilter),
"email_sent_for_modification",
"is_followed_by_us",
AuthorKindFilter,
"status",
Expand Down Expand Up @@ -542,6 +554,33 @@ class TenderAdmin(FieldsetsInlineMixin, admin.ModelAdmin):
class Media:
js = ["/static/js/admin_tender_confirmation.js"]

def handle_email_sent_for_modification(self, request, obj):
"""
Send an email to the author and set some fields with 'set_modification_request'
Display an error message if the email can't be sent
"""
try:
send_tender_author_modification_request(tender=obj)
obj.set_modification_request()
self.message_user(request, "Une demande de modification a été envoyée à l'auteur du besoin")
except Exception as e:
self.message_user(
request,
"Erreur lors de l'envoi de la demande de modification : veuillez contacter le support.",
level="error",
)
logger.error(f"Exception when sending mail {e}")
finally:
return HttpResponseRedirect(".")

def handle_rejected_status(self, request, obj):
"""
If tender status is REJECTED, send an email to the author and redirect to the same page.
"""
send_tender_author_reject_message(tender=obj)
self.message_user(request, "Un email a été envoyé à l'auteur du besoin")
return HttpResponseRedirect(".")

def get_queryset(self, request):
qs = super().get_queryset(request)
qs = qs.select_related("author")
Expand Down Expand Up @@ -583,7 +622,6 @@ def save_model(self, request, obj: Tender, form, change):
"""
if not obj.id and not obj.author_id:
obj.author = request.user
obj.save()

def save_formset(self, request, form, formset, change):
"""
Expand Down Expand Up @@ -802,6 +840,10 @@ def response_change(self, request, obj: Tender):
# we don't need to send it in the crm, parteners manage them
self.message_user(request, "Ce dépôt de besoin a été validé. Il sera envoyé aux partenaires :)")
return HttpResponseRedirect(".")
if request.POST.get("_send_modification_request"):
return self.handle_email_sent_for_modification(request, obj)
if obj.status == tender_constants.STATUS_REJECTED:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pour moi, le status est à juste titre en lecture seule, donc je ne vois pas comment on peut tomber dans ce cas ? Ta volonté était bien de permettre le rejet depuis l'admin ou uniquement après l'expiration de la demande de modifications ?

return self.handle_rejected_status(request, obj)
elif request.POST.get("_restart_tender"):
restart_send_tender_task(tender=obj)
self.message_user(request, "Ce dépôt de besoin a été renvoyé aux structures")
Expand Down
2 changes: 2 additions & 0 deletions lemarche/tenders/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,14 @@
STATUS_PUBLISHED = "PUBLISHED"
STATUS_VALIDATED = "VALIDATED"
STATUS_SENT = "SENT"
STATUS_REJECTED = "REJECTED"

STATUS_CHOICES = (
(STATUS_DRAFT, "Brouillon"),
(STATUS_PUBLISHED, "Publié"),
(STATUS_VALIDATED, "Validé"),
(STATUS_SENT, "Envoyé"),
(STATUS_REJECTED, "Rejeté"),
)


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from datetime import timedelta

from django.core.management.base import BaseCommand
from django.utils import timezone

from lemarche.tenders import constants as tender_constants
from lemarche.tenders.models import Tender


class Command(BaseCommand):
help = "Si aucune modification n'est apportée dans les 10 jours suivant la demande, le Besoin est rejeté."

def handle(self, *args, **options):
threshold_date = timezone.now() - timedelta(days=10)
tenders_to_update = []

tenders_draft = Tender.objects.filter(status=tender_constants.STATUS_DRAFT)
tenders_draft_count = tenders_draft.count()

self.stdout.write(f"Besoin(s) à traiter : {tenders_draft_count}")

for tender in tenders_draft:
email_sent_at = None
for log_entry in tender.logs:
if log_entry.get("action") == "send tender author modification request":
email_sent_at = log_entry.get("date")
break

if email_sent_at:
email_sent_at_date = timezone.datetime.fromisoformat(email_sent_at)
if email_sent_at_date <= threshold_date:
tenders_to_update.append(tender)

for tender in tenders_to_update:
tender.status = tender_constants.STATUS_REJECTED
tender.save(update_fields=["status"])

if not tenders_to_update:
self.stdout.write("Aucun besoin rejeté")
elif len(tenders_to_update) == 1:
self.stdout.write("1 besoin rejeté")
else:
self.stdout.write(f"{len(tenders_to_update)} besoins rejetés")
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Generated by Django 4.2.15 on 2025-01-16 04:01

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("tenders", "0095_remove_tender_accept_cocontracting_and_more"),
]

operations = [
migrations.AddField(
model_name="tender",
name="email_sent_for_modification",
field=models.BooleanField(
default=False,
help_text="Envoyer un e-mail pour demander des modifications",
verbose_name="Modifications requises",
),
),
migrations.AlterField(
model_name="tender",
name="status",
field=models.CharField(
choices=[
("DRAFT", "Brouillon"),
("PUBLISHED", "Publié"),
("VALIDATED", "Validé"),
("SENT", "Envoyé"),
("REJECTED", "Rejeté"),
],
default="DRAFT",
max_length=10,
verbose_name="Statut",
),
),
]
26 changes: 26 additions & 0 deletions lemarche/tenders/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,9 @@ class Tender(models.Model):
)
# admins
is_followed_by_us = models.BooleanField("Suivi par l'équipe", null=True)
email_sent_for_modification = models.BooleanField(
"Modifications requises", help_text="Envoyer un e-mail pour demander des modifications", default=False
)
# Admin specific for proj
proj_resulted_in_reserved_tender = models.BooleanField(
"Abouti à un appel d’offre (uniquement sourcing)", null=True
Expand Down Expand Up @@ -708,6 +711,29 @@ def __init__(self, *args, **kwargs):
for field_name in self.TRACK_UPDATE_FIELDS:
setattr(self, f"__previous_{field_name}", getattr(self, field_name))

def reset_modification_request(self):
"""
Reset modification request when republishing a tender.
This method can only be called on Tender updates if status is changed to published
"""
if self.status == self.STATUS_PUBLISHED and self.email_sent_for_modification:
self.email_sent_for_modification = False
self.save(update_fields=["email_sent_for_modification"])

def set_modification_request(self):
"""
Set modification request when republishing a tender.
This method can only be called on Tender updates if status is changed to published
"""
self.email_sent_for_modification = True
self.status = tender_constants.STATUS_DRAFT
log_item = {
"action": "send tender author modification request",
"date": timezone.now().isoformat(),
}
self.logs.append(log_item)
self.save(update_fields=["email_sent_for_modification", "status", "logs"])

def set_slug(self, with_uuid=False):
"""
The slug field should be unique.
Expand Down
Loading
Loading