Repository: ImperialCollegeLondon/proCAT
Pull request #106: 12 project model
Author: login: dalonsoa
Created: 2025-05-14T07:51:28Z | Merged: 2025-05-22T17:03:23Z

# Description

Adds the `Project` model, including a `clean` method to do some validation on the inputs. The model has been added to the Admin to have and initial access to the tooling. 

Here a few screenshots of the Project Admin, the list view and the edit view.

<img width="596" alt="image" src="https://github.com/user-attachments/assets/346c8f63-4df4-4093-8436-ed1a329439e9" />

<img width="503" alt="image" src="https://github.com/user-attachments/assets/cd5c8f46-8457-47fb-8dda-17fc22949dd8" />

Fixes #12 

## Type of change

- [ ] Documentation (non-breaking change that adds or improves the documentation)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Optimization (non-breaking, back-end change that speeds up the code)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Breaking change (whatever its nature)

## Key checklist

- [x] All tests pass (eg. `python -m pytest`)
- [ ] The documentation builds and looks OK (eg. `mkdocs serve`)
- [x] Pre-commit hooks run successfully (eg. `pre-commit run --all-files`)

## Further checks

- [x] Code is commented, particularly in hard-to-understand areas
- [x] Tests added or an issue has been opened to tackle that in the future. (Indicate issue here: # (issue))


Linked issue #12: Add `Project` model
This model should include the following fields:

- Project name
- Nature (`Support` or `Standard`). Typically, support projects cannot be allocated to sprints easily, the work there is more lightweight and ad hoc, sometimes at short notice. 
- PI
- Department (see #103 )
- Start date
- End date
- Project lead from our side (must be a member of the team)
- Status (Draft, Not started, Active, Completed)
- Charging method (based on actual effort, pro-rata or all in one go in a specific month)

All fields should be mandatory unless the `status` is `Draft`.

To be able to set a project as "Active", it needs to have at least one funding source defined. See #13 .

Diff:
diff --git a/main/admin.py b/main/admin.py
index 7f04a782..bb5e8686 100644
--- a/main/admin.py
+++ b/main/admin.py
@@ -3,8 +3,23 @@
 from django.contrib import admin
 from django.contrib.auth.admin import UserAdmin
 
-from .models import ActivityCode, Department, User
+from .models import ActivityCode, Department, Project, User
 
 admin.site.register(User, UserAdmin)
 admin.site.register(Department)
 admin.site.register(ActivityCode)
+
+
+@admin.register(Project)
+class ProjectAdmin(admin.ModelAdmin):  # type: ignore [type-arg]
+    """Admin class for the Project model."""
+
+    list_display = (
+        "name",
+        "nature",
+        "department",
+        "status",
+        "charging",
+        "start_date",
+        "end_date",
+    )
diff --git a/main/migrations/0005_project.py b/main/migrations/0005_project.py
new file mode 100644
index 00000000..b0506a76
--- /dev/null
+++ b/main/migrations/0005_project.py
@@ -0,0 +1,30 @@
+# Generated by Django 5.2.1 on 2025-05-13 16:40
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('main', '0004_initial_activity_codes'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Project',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(help_text='Name of the project.', unique=True, verbose_name='Name')),
+                ('nature', models.CharField(choices=[('Support', 'Support'), ('Standard', 'Standard')], help_text='Nature of the project.  Typically, support projects cannot be allocated to sprints easily, the work there is more lightweight and ad hoc, sometimes at short notice.', verbose_name='Nature')),
+                ('pi', models.CharField(help_text='Name of the principal investigator responsible for the project. It should be the actual grant holder, not the main point of contact', verbose_name='Principal Investigator')),
+                ('start_date', models.DateField(blank=True, help_text='Start date for the project.', null=True, verbose_name='Start date')),
+                ('end_date', models.DateField(blank=True, help_text='End date for the project.', null=True, verbose_name='End date')),
+                ('status', models.CharField(choices=[('Draft', 'Draft'), ('Not started', 'Not started'), ('Active', 'Active'), ('Completted', 'Completted')], default='Draft', help_text="Status of the project. Unless the status is 'Draft', most other fields are mandatory.", verbose_name='Status')),
+                ('charging', models.CharField(choices=[('Actual', 'Actual'), ('Pro-rata', 'Pro-rata'), ('Manual', 'Manual')], default='Actual', help_text="Method for charging the costs of the project. 'Actual' is based on timesheet records. 'Pro-rata' charges the same amount every month. Finally, in 'Manual' the charges are scheduled manually.", verbose_name='Charging method')),
+                ('department', models.ForeignKey(help_text='The department in which the research project is based, primarily.', on_delete=django.db.models.deletion.PROTECT, to='main.department')),
+                ('lead', models.ForeignKey(blank=True, help_text='Project lead from the RSE side.', null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+    ]
diff --git a/main/models.py b/main/models.py
index b365a951..aba7c4d2 100644
--- a/main/models.py
+++ b/main/models.py
@@ -1,6 +1,7 @@
 """Models module for main app."""
 
 from django.contrib.auth.models import AbstractUser
+from django.core.exceptions import ValidationError
 from django.db import models
 
 
@@ -74,3 +75,103 @@ class ActivityCode(models.Model):
     def __str__(self) -> str:
         """String representation of the Activity Code object."""
         return f"{self.code} - {self.description}"
+
+
+class Project(models.Model):
+    """Software project details."""
+
+    _NATURE = (("Support", "Support"), ("Standard", "Standard"))
+    _STATUS = (
+        ("Draft", "Draft"),
+        ("Not started", "Not started"),
+        ("Active", "Active"),
+        ("Completed", "Completed"),
+    )
+    _CHARGING = (
+        ("Actual", "Actual"),
+        ("Pro-rata", "Pro-rata"),
+        ("Manual", "Manual"),
+    )
+
+    name = models.CharField(
+        "Name",
+        unique=True,
+        blank=False,
+        null=False,
+        help_text="Name of the project.",
+    )
+    nature = models.CharField(
+        "Nature",
+        blank=False,
+        null=False,
+        choices=_NATURE,
+        help_text="Nature of the project.  Typically, support projects cannot be "
+        "allocated to sprints easily, the work there is more lightweight and ad hoc, "
+        "sometimes at short notice.",
+    )
+    pi = models.CharField(
+        "Principal Investigator",
+        blank=False,
+        null=False,
+        help_text="Name of the principal investigator responsible for the project. "
+        "It should be the actual grant holder, not the main point of contact",
+    )
+    department = models.ForeignKey(
+        Department,
+        on_delete=models.PROTECT,
+        blank=False,
+        null=False,
+        help_text="The department in which the research project is based, primarily.",
+    )
+    start_date = models.DateField(
+        "Start date", null=True, blank=True, help_text="Start date for the project."
+    )
+    end_date = models.DateField(
+        "End date", null=True, blank=True, help_text="End date for the project."
+    )
+    lead = models.ForeignKey(
+        User,
+        on_delete=models.PROTECT,
+        null=True,
+        blank=True,
+        help_text="Project lead from the RSE side.",
+    )
+    status = models.CharField(
+        "Status",
+        blank=False,
+        null=False,
+        default="Draft",
+        choices=_STATUS,
+        help_text="Status of the project. Unless the status is 'Draft', most other "
+        "fields are mandatory.",
+    )
+    charging = models.CharField(
+        "Charging method",
+        blank=False,
+        null=False,
+        default="Actual",
+        choices=_CHARGING,
+        help_text="Method for charging the costs of the project. 'Actual' is based"
+        " on timesheet records. 'Pro-rata' charges the same amount every month. "
+        "Finally, in 'Manual' the charges are scheduled manually.",
+    )
+
+    def __str__(self) -> str:
+        """String representation of the Project object."""
+        return self.name
+
+    def clean(self) -> None:
+        """Ensure that all fields have a value unless the status is 'Draft'.
+
+        It also checks that, if present, the end date is after the start date.
+        """
+        if self.status == "Draft":
+            return super().clean()
+
+        if not self.start_date or not self.end_date or not self.lead:
+            raise ValidationError(
+                "All fields are mandatory except if Project status is 'Draft'."
+            )
+
+        if self.end_date <= self.start_date:
+            raise ValidationError("The end date must be after the start date.")
diff --git a/tests/main/test_models.py b/tests/main/test_models.py
index 0fec1d1d..2473a231 100644
--- a/tests/main/test_models.py
+++ b/tests/main/test_models.py
@@ -1,5 +1,7 @@
 """Tests for the models."""
 
+import pytest
+
 
 def test_department_model_str():
     """Test the object string for the model."""
@@ -15,3 +17,61 @@ def test_activity_code_model_str():
 
     dep = models.ActivityCode(code="1234", description="Some code", notes="None")
     assert str(dep) == "1234 - Some code"
+
+
+class TestProject:
+    """Tests for the project model."""
+
+    def test_model_str(self):
+        """Test the object string for the model."""
+        from main import models
+
+        project = models.Project(name="ProCAT")
+        assert str(project) == "ProCAT"
+
+    def test_clean_when_draft(self):
+        """Test the clean method."""
+        from main import models
+
+        project = models.Project(name="ProCAT")
+        project.clean()
+
+    def test_clean_when_not_draft(self, user):
+        """Test the clean method."""
+        from datetime import datetime, timedelta
+
+        from django.core.exceptions import ValidationError
+
+        from main import models
+
+        # Mandatory fields are present
+        project = models.Project(name="ProCAT", status="Active")
+        with pytest.raises(
+            ValidationError,
+            match="All fields are mandatory except if Project status is 'Draft'.",
+        ):
+            project.clean()
+
+        # The end date is after the start date
+        project = models.Project(
+            name="ProCAT",
+            lead=user,
+            start_date=datetime.now().date(),
+            end_date=datetime.now().date(),
+            status="Active",
+        )
+        with pytest.raises(
+            ValidationError,
+            match="The end date must be after the start date.",
+        ):
+            project.clean()
+
+        # All good!
+        project = models.Project(
+            name="ProCAT",
+            lead=user,
+            start_date=datetime.now().date(),
+            end_date=datetime.now().date() + timedelta(days=42),
+            status="Active",
+        )
+        project.clean()


Reviews:
user: login: jfcoker
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T14:36:34Z
user: login: jfcoker
body: Just spotted a typo :)
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T15:04:53Z
user: login: stephwills
body: this all makes sense to me
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T13:41:43Z
user: login: jfcoker
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T13:50:37Z
user: login: jfcoker
body: Looks good :)
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:26:11Z

Review comments:
diff_hunk: @@ -74,3 +75,103 @@ class ActivityCode(models.Model):
     def __str__(self) -> str:
         """String representation of the Activity Code object."""
         return f"{self.code} - {self.description}"
+
+
+class Project(models.Model):
+    """Software project details."""
+
+    _NATURE = (("Support", "Support"), ("Standard", "Standard"))
+    _STATUS = (
+        ("Draft", "Draft"),
+        ("Not started", "Not started"),
+        ("Active", "Active"),
+        ("Completted", "Completted"),
path: main/models.py
user: login: jfcoker
body: Just a small typo, should be 'Completed' with only one 't' :)
created_at: 2025-05-20T14:36:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,3 +75,103 @@ class ActivityCode(models.Model):
     def __str__(self) -> str:
         """String representation of the Activity Code object."""
         return f"{self.code} - {self.description}"
+
+
+class Project(models.Model):
+    """Software project details."""
+
+    _NATURE = (("Support", "Support"), ("Standard", "Standard"))
+    _STATUS = (
+        ("Draft", "Draft"),
+        ("Not started", "Not started"),
+        ("Active", "Active"),
+        ("Completted", "Completted"),
path: main/models.py
user: login: jfcoker
body: ```suggestion
        ("Completed", "Completed"),
```
created_at: 2025-05-20T15:04:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,3 +75,103 @@ class ActivityCode(models.Model):
     def __str__(self) -> str:
         """String representation of the Activity Code object."""
         return f"{self.code} - {self.description}"
+
+
+class Project(models.Model):
+    """Software project details."""
+
+    _NATURE = (("Support", "Support"), ("Standard", "Standard"))
+    _STATUS = (
+        ("Draft", "Draft"),
+        ("Not started", "Not started"),
+        ("Active", "Active"),
+        ("Completted", "Completted"),
+    )
+    _CHARGING = (
+        ("Actual", "Actual"),
+        ("Pro-rata", "Pro-rata"),
+        ("Manual", "Manual"),
+    )
+
+    name = models.CharField(
+        "Name",
+        unique=True,
+        blank=False,
+        null=False,
+        help_text="Name of the project.",
+    )
+    nature = models.CharField(
+        "Nature",
+        blank=False,
+        null=False,
+        choices=_NATURE,
+        help_text="Nature of the project.  Typically, support projects cannot be "
+        "allocated to sprints easily, the work there is more lightweight and ad hoc, "
+        "sometimes at short notice.",
+    )
+    pi = models.CharField(
+        "Principal Investigator",
+        blank=False,
+        null=False,
+        help_text="Name of the principal investigator responsible for the project. "
+        "It should be the actual grant holder, not the main point of contact",
+    )
+    department = models.ForeignKey(
+        Department,
+        on_delete=models.PROTECT,
+        blank=False,
+        null=False,
+        help_text="The department in which the research project is based, primarily.",
+    )
+    start_date = models.DateField(
+        "Start date", null=True, blank=True, help_text="Start date for the project."
+    )
+    end_date = models.DateField(
+        "End date", null=True, blank=True, help_text="End date for the project."
+    )
+    lead = models.ForeignKey(
+        User,
+        on_delete=models.PROTECT,
+        null=True,
+        blank=True,
+        help_text="Project lead from the RSE side.",
+    )
+    status = models.CharField(
+        "Status",
+        blank=False,
+        null=False,
+        default="Draft",
+        choices=_STATUS,
+        help_text="Status of the project. Unless the status is 'Draft', most other "
+        "fields are mandatory.",
+    )
+    charging = models.CharField(
+        "Charging method",
+        blank=False,
+        null=False,
+        default="Actual",
+        choices=_CHARGING,
+        help_text="Method for charging the costs of the project. 'Actual' is based"
+        " on timesheet records. 'Pro-rata' charges the same amount every month. "
+        "Finally, in 'Manual' the charges are scheduled manually.",
+    )
+
+    def __str__(self) -> str:
+        """String representation of the Project object."""
+        return self.name
+
+    def clean(self) -> None:
+        """Ensure that all fields have a value unless the status is 'Draft'.
+
+        It also checks that, if present, the end date is after the start date.
+        """
+        if self.status == "Draft":
+            return super().clean()
+
+        if not self.start_date or not self.end_date or not self.lead:
+            raise ValidationError(
+                "All fields are mandatory except if Project status id 'Draft'."
path: main/models.py
user: login: jfcoker
body: Small typo

```suggestion
                "All fields are mandatory except if Project status is 'Draft'."
```
created_at: 2025-05-21T13:50:37Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov-commenter
created_at: 2025-05-14T08:10:25Z
body: ## [Codecov](https://app.codecov.io/gh/ImperialCollegeLondon/proCAT/pull/106?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ImperialCollegeLondon) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 93.52%. Comparing base [(`289d109`)](https://app.codecov.io/gh/ImperialCollegeLondon/proCAT/commit/289d1092054cf25be5eb0f33d08049f858fa693d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ImperialCollegeLondon) to head [(`69ed02a`)](https://app.codecov.io/gh/ImperialCollegeLondon/proCAT/commit/69ed02a034661e0897b23268cf4939a4205b280d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ImperialCollegeLondon).
> Report is 9 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #106      +/-   ##
==========================================
+ Coverage   91.58%   93.52%   +1.93%     
==========================================
  Files          16       17       +1     
  Lines         107      139      +32     
  Branches        0        3       +3     
==========================================
+ Hits           98      130      +32     
  Misses          9        9              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/ImperialCollegeLondon/proCAT/pull/106?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ImperialCollegeLondon).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ImperialCollegeLondon).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: jfcoker
created_at: 2025-05-20T15:06:36Z
body: What is the connection between activity codes and projects? Should each project have an activity code? Right now they don't seem to be related.
author_association: CONTRIBUTOR
user: login: dalonsoa
created_at: 2025-05-22T06:03:48Z
body: > What is the connection between activity codes and projects? Should each project have an activity code? Right now they don't seem to be related.

No relationship. I just happen to feel like tackling this one. To be honest, I could have perfectly have left it for you to do.
author_association: COLLABORATOR