diff --git a/hypha/apply/funds/tests/test_views.py b/hypha/apply/funds/tests/test_views.py index e95d3e229f..f3979b9483 100644 --- a/hypha/apply/funds/tests/test_views.py +++ b/hypha/apply/funds/tests/test_views.py @@ -329,6 +329,7 @@ def test_can_create_project(self): "project_create_form": "", "project_lead": self.user.id, "project_initial_status": CONTRACTING, + "project_end": timezone.now().date(), "submission": self.submission.id, }, view_name="create_project", diff --git a/hypha/apply/funds/views/submission_edit.py b/hypha/apply/funds/views/submission_edit.py index a711ea9e88..be031b45d5 100644 --- a/hypha/apply/funds/views/submission_edit.py +++ b/hypha/apply/funds/views/submission_edit.py @@ -468,7 +468,7 @@ def post(self, *args, **kwargs): return render( self.request, "funds/modals/create_project_form.html", - context={"form": form, "value": _("Confirm"), "object": self.object}, + context={"form": form, "value": _("Confirm"), "object": self.submission}, status=400, ) diff --git a/hypha/apply/projects/forms/__init__.py b/hypha/apply/projects/forms/__init__.py index a0fd67814a..85642d0089 100644 --- a/hypha/apply/projects/forms/__init__.py +++ b/hypha/apply/projects/forms/__init__.py @@ -18,6 +18,7 @@ SkipPAFApprovalProcessForm, StaffUploadContractForm, SubmitContractDocumentsForm, + UpdateProjectDatesForm, UpdateProjectLeadForm, UpdateProjectTitleForm, UploadContractDocumentForm, @@ -44,6 +45,7 @@ "UploadContractDocumentForm", "StaffUploadContractForm", "UploadDocumentForm", + "UpdateProjectDatesForm", "UpdateProjectLeadForm", "CreateInvoiceForm", "ChangeInvoiceStatusForm", diff --git a/hypha/apply/projects/forms/project.py b/hypha/apply/projects/forms/project.py index 3e9e01a02e..431c68759c 100644 --- a/hypha/apply/projects/forms/project.py +++ b/hypha/apply/projects/forms/project.py @@ -1,4 +1,7 @@ +from datetime import date + from django import forms +from django.conf import settings from django.contrib.auth import get_user_model from django.db.models import Count, Q from django.utils.text import slugify @@ -10,6 +13,7 @@ get_project_default_status, get_project_status_options, ) +from hypha.apply.projects.templatetags.project_tags import show_start_date from hypha.apply.stream_forms.fields import SingleFileField from hypha.apply.stream_forms.forms import StreamBaseForm from hypha.apply.users.roles import STAFF_GROUP_NAME @@ -86,16 +90,20 @@ class ProjectCreateForm(forms.Form): ) project_lead = forms.ModelChoiceField( - label=_("Select Project Lead"), queryset=User.objects.all() + label=_("Select project lead"), queryset=User.objects.all() ) # Set the initial value to the settings default if valid, otherwise fall back to draft project_initial_status = forms.ChoiceField( - label=_("Initial Project Status"), + label=_("Initial project status"), choices=get_project_status_options(), initial=get_project_default_status(), ) + project_end = forms.DateField( + label=_("Project end date"), + ) + def __init__(self, *args, instance=None, **kwargs): super().__init__(*args, **kwargs) @@ -119,7 +127,24 @@ def save(self, *args, **kwargs): submission = self.cleaned_data["submission"] lead = self.cleaned_data["project_lead"] status = self.cleaned_data["project_initial_status"] - return Project.create_from_submission(submission, lead=lead, status=status) + end_date = self.cleaned_data["project_end"] + + start_date = None + + if not settings.PROJECTS_START_AFTER_CONTRACTING or status in [ + INVOICING_AND_REPORTING, + CLOSING, + COMPLETE, + ]: + start_date = date.today() + + return Project.create_from_submission( + submission, + lead=lead, + status=status, + end_date=end_date, + start_date=start_date, + ) class MixedMetaClass(type(StreamBaseForm), type(forms.ModelForm)): @@ -444,3 +469,28 @@ class Meta: def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) + + +class UpdateProjectDatesForm(forms.ModelForm): + class Meta: + fields = ["proposed_start", "proposed_end"] + model = Project + + def clean(self): + cleaned_data = super().clean() + if ( + show_start_date(self.instance) + and cleaned_data["proposed_start"] >= cleaned_data["proposed_end"] + ): + self.add_error( + "proposed_end", _("The end date must be after the start date.") + ) + + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, **kwargs) + # Only show the start date field if relevant + if not show_start_date(self.instance): + proposed_start = self.fields["proposed_start"] + proposed_start.disabled = True + proposed_start.required = False + proposed_start.widget = proposed_start.hidden_widget() diff --git a/hypha/apply/projects/migrations/0100_alter_project_proposed_end_and_more.py b/hypha/apply/projects/migrations/0100_alter_project_proposed_end_and_more.py new file mode 100644 index 0000000000..b9d30c4cef --- /dev/null +++ b/hypha/apply/projects/migrations/0100_alter_project_proposed_end_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.20 on 2025-04-10 17:27 + +import datetime +from django.db import migrations, models + + +def migration_set_project_start_date(apps, schema_editor): + Project = apps.get_model("application_projects", "Project") + for project in Project.objects.all(): + if not project.proposed_start: + project.proposed_start = project.created_at + project.save(update_fields=["proposed_start"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("application_projects", "0099_remove_reportconfig_project_and_more"), + ] + + operations = [ + migrations.RunPython(migration_set_project_start_date), + migrations.AlterField( + model_name="project", + name="proposed_end", + field=models.DateField(null=True, verbose_name="Proposed end date"), + ), + migrations.AlterField( + model_name="project", + name="proposed_start", + field=models.DateField( + default=datetime.date.today, + null=True, + verbose_name="Proposed start date", + ), + ), + ] diff --git a/hypha/apply/projects/models/project.py b/hypha/apply/projects/models/project.py index f13a1129a1..5572a793c0 100644 --- a/hypha/apply/projects/models/project.py +++ b/hypha/apply/projects/models/project.py @@ -1,4 +1,5 @@ import logging +from datetime import date from django import forms from django.apps import apps @@ -20,7 +21,7 @@ Value, When, ) -from django.db.models.functions import Cast, Coalesce +from django.db.models.functions import Coalesce from django.db.models.signals import post_delete from django.dispatch.dispatcher import receiver from django.urls import reverse @@ -164,21 +165,6 @@ def with_outstanding_reports(self): ) ) - def with_start_date(self): - return self.annotate( - start=Cast( - Subquery( - Contract.objects.filter( - project=OuterRef("pk"), - ) - .approved() - .order_by("approved_at") - .values("approved_at")[:1] - ), - models.DateField(), - ) - ) - def for_table(self): return ( self.with_amount_paid() @@ -259,8 +245,10 @@ class Project(BaseStreamForm, AccessFormData, models.Model): decimal_places=2, validators=[MinValueValidator(limit_value=0)], ) - proposed_start = models.DateTimeField(_("Proposed Start Date"), null=True) - proposed_end = models.DateTimeField(_("Proposed End Date"), null=True) + proposed_start = models.DateField( + _("Proposed start date"), null=True, default=date.today + ) + proposed_end = models.DateField(_("Proposed end date"), null=True) status = models.TextField(choices=PROJECT_STATUS_CHOICES, default=DRAFT) @@ -321,7 +309,9 @@ def get_address_display(self): return "" # todo: need to figure out @classmethod - def create_from_submission(cls, submission, lead=None, status=None): + def create_from_submission( + cls, submission, lead=None, status=None, end_date=None, start_date=None + ): """ Create a Project from the given submission. @@ -357,26 +347,18 @@ def create_from_submission(cls, submission, lead=None, status=None): title=submission.title, status=status, lead=lead if lead else None, + proposed_end=end_date, + proposed_start=start_date, value=submission.form_data.get("value", 0), ) - @property - def start_date(self): - # Assume project starts when OTF are happy with the first signed contract - first_approved_contract = ( - self.contracts.approved().order_by("approved_at").first() - ) - if not first_approved_contract: - return None - return first_approved_contract.approved_at.date() - @property def end_date(self): # Aiming for the proposed end date as the last day of the project # If still ongoing assume today is the end if self.proposed_end: return max( - self.proposed_end.date(), + self.proposed_end, timezone.now().date(), ) return timezone.now().date() diff --git a/hypha/apply/projects/reports/models.py b/hypha/apply/projects/reports/models.py index 7a8dd7eb91..dc88876c63 100644 --- a/hypha/apply/projects/reports/models.py +++ b/hypha/apply/projects/reports/models.py @@ -59,9 +59,7 @@ def for_table(self): project_start_date=Subquery( Project.objects.filter( pk=OuterRef("project_id"), - ) - .with_start_date() - .values("start")[:1] + ).values("proposed_start")[:1] ), start=Case( When( @@ -176,7 +174,7 @@ def start_date(self): if last_report: return last_report.end_date + relativedelta(days=1) - return self.project.start_date + return self.project.proposed_start class ReportVersion(BaseStreamForm, AccessFormData, models.Model): @@ -329,14 +327,14 @@ def current_due_report(self): return None # Project not started - no reporting required - if not self.project.start_date: + if not self.project.proposed_start: return None today = timezone.now().date() last_report = self.last_report() - schedule_date = self.schedule_start or self.project.start_date + schedule_date = self.schedule_start or self.project.proposed_start if last_report: # Frequency is one time and last report exists - no reporting required anymore diff --git a/hypha/apply/projects/reports/tests/test_models.py b/hypha/apply/projects/reports/tests/test_models.py index 69c3abe5f8..bead1fee5c 100644 --- a/hypha/apply/projects/reports/tests/test_models.py +++ b/hypha/apply/projects/reports/tests/test_models.py @@ -80,7 +80,9 @@ def test_no_report_creates_report(self): # combined => 31th + 1 month = 30th - 1 day = 29th (wrong) # separate => 31th - 1 day = 30th + 1 month = 30th (correct) next_due = ( - report.project.start_date - relativedelta(days=1) + relativedelta(months=1) + report.project.proposed_start + - relativedelta(days=1) + + relativedelta(months=1) ) assert Report.objects.count() == 1 assert report.end_date == next_due diff --git a/hypha/apply/projects/reports/views.py b/hypha/apply/projects/reports/views.py index c8b194be43..913d131433 100644 --- a/hypha/apply/projects/reports/views.py +++ b/hypha/apply/projects/reports/views.py @@ -462,7 +462,7 @@ def get_form_kwargs(self, **kwargs): } else: kwargs["initial"] = { - "start": self.project.start_date, + "start": self.project.proposed_start, } return kwargs diff --git a/hypha/apply/projects/tables.py b/hypha/apply/projects/tables.py index df44ba4eb4..5d833c56c7 100644 --- a/hypha/apply/projects/tables.py +++ b/hypha/apply/projects/tables.py @@ -141,6 +141,7 @@ class BaseProjectsTable(tables.Table): fund = tables.Column(verbose_name=_("Fund"), accessor="submission__page") reporting = tables.Column(verbose_name=_("Reporting"), accessor="pk") last_payment_request = tables.DateColumn() + end_date = tables.DateColumn(verbose_name=_("End date"), accessor="proposed_end") def order_reporting(self, qs, is_descending): direction = "-" if is_descending else "" @@ -176,7 +177,7 @@ class Meta: "fund", "reporting", "last_payment_request", - "created_at", + "end_date", ] model = Project template_name = "application_projects/tables/table.html" @@ -192,7 +193,7 @@ class Meta: "lead", "reporting", "last_payment_request", - "created_at", + "end_date", ] model = Project orderable = False @@ -254,10 +255,10 @@ class Meta: "fund", "reporting", "last_payment_request", - "created_at", + "end_date", ] model = Project orderable = True - order_by = ("-created_at",) + order_by = ("end_date",) template_name = "application_projects/tables/table.html" attrs = {"class": "projects-table"} diff --git a/hypha/apply/projects/templates/application_projects/modals/project_dates_update.html b/hypha/apply/projects/templates/application_projects/modals/project_dates_update.html new file mode 100644 index 0000000000..7a3248e18b --- /dev/null +++ b/hypha/apply/projects/templates/application_projects/modals/project_dates_update.html @@ -0,0 +1,6 @@ +{% load i18n %} +{% modal_title %}{% trans "Update project dates" %}{% endmodal_title %} + +
+ {% include 'includes/dialog_form_base.html' with form=form value=value %} +
diff --git a/hypha/apply/projects/templates/application_projects/partials/project_information.html b/hypha/apply/projects/templates/application_projects/partials/project_information.html new file mode 100644 index 0000000000..71736a4060 --- /dev/null +++ b/hypha/apply/projects/templates/application_projects/partials/project_information.html @@ -0,0 +1,56 @@ +{% load i18n heroicons project_tags %} + +
+
{% trans "Contractor" %}
+

{{ object.user |default:"-" }}

+
+ +
+
{% trans "E-mail" %}
+ {% if object.user.email %} + {{ object.user.email }} + {% else %} + - + {% endif %} +
+ +
+ {% show_start_date object as show_start %} +
+ {% trans "Start date" %} + {% if request.user.is_apply_staff and show_start %} + + {% heroicon_solid "pencil-square" class="inline mt-2 align-text-bottom ms-1" aria_hidden=true size=20 %} + {% trans "edit dates" %} + + {% endif %} +
+

+ {% if show_start %} + {{ object.proposed_start|date:"SHORT_DATE_FORMAT"|default:"-" }} + {% else %} + {% trans "Awaiting contract finalization..." %} + {% endif %} +

+
+ +
+
+ {% trans "End date" %} + {% if request.user.is_apply_staff %} + + {% heroicon_solid "pencil-square" class="inline mt-2 align-text-bottom ms-1" aria_hidden=true size=20 %} + {% trans "edit dates" %} + + {% endif %} +
+

{{ object.proposed_end|date:"SHORT_DATE_FORMAT"|default:"-" }}

+
diff --git a/hypha/apply/projects/templates/application_projects/partials/project-lead.html b/hypha/apply/projects/templates/application_projects/partials/project_lead.html similarity index 100% rename from hypha/apply/projects/templates/application_projects/partials/project-lead.html rename to hypha/apply/projects/templates/application_projects/partials/project_lead.html diff --git a/hypha/apply/projects/templates/application_projects/project_detail.html b/hypha/apply/projects/templates/application_projects/project_detail.html index c5ed6027b6..82b97752bf 100644 --- a/hypha/apply/projects/templates/application_projects/project_detail.html +++ b/hypha/apply/projects/templates/application_projects/project_detail.html @@ -56,20 +56,15 @@

{% trans "Project Information" %}

-
-
-
{% trans "Contractor" %}
-

{{ object.user |default:"-" }}

-
- -
-
{% trans "E-mail" %}
- {% if object.user.email %} - {{ object.user.email }} - {% else %} - - - {% endif %} -
+
+ + + +
{% user_can_view_invoices object user as can_view_invoices %} @@ -99,9 +94,9 @@
{% trans "E-mail" %}
{% user_next_step_on_project object user request=request as next_step %} {% if next_step %}