Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
59 changes: 51 additions & 8 deletions core/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,9 +502,19 @@ class Meta:


class CommitmentForm(forms.ModelForm):
aggregation_type = forms.ChoiceField(
choices=[('project', 'Project'), ('subproject', 'Subproject'), ('context', 'Context'), ('tag', 'Tag')],
required=False,
widget=forms.Select(attrs={'class': 'half-width'}),
)
project = forms.ModelChoiceField(queryset=Projects.objects.none(), required=False, widget=forms.Select(attrs={'class': 'half-width'}))
subproject = forms.ModelChoiceField(queryset=SubProjects.objects.none(), required=False, widget=forms.Select(attrs={'class': 'half-width'}))
context = forms.ModelChoiceField(queryset=Context.objects.none(), required=False, widget=forms.Select(attrs={'class': 'half-width'}))
tag = forms.ModelChoiceField(queryset=Tag.objects.none(), required=False, widget=forms.Select(attrs={'class': 'half-width'}))

class Meta:
model = Commitment
fields = ['commitment_type', 'period', 'target', 'banking_enabled', 'max_balance', 'min_balance']
fields = ['aggregation_type', 'project', 'subproject', 'context', 'tag', 'commitment_type', 'period', 'target', 'banking_enabled', 'max_balance', 'min_balance']
widgets = {
'commitment_type': forms.Select(attrs={'class': 'half-width'}),
'period': forms.Select(attrs={'class': 'half-width'}),
Expand All @@ -514,6 +524,7 @@ class Meta:
'banking_enabled': forms.CheckboxInput(),
}
labels = {
'aggregation_type': 'Aggregation Type',
'commitment_type': 'Commitment Type',
'period': 'Period',
'target': 'Target',
Expand All @@ -529,32 +540,64 @@ class Meta:

def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None)
self.project = kwargs.pop('project', None)
self.project_obj = kwargs.pop('project', None)
super().__init__(*args, **kwargs)

if self.user is not None and self.instance.user_id is None:
self.instance.user = self.user

if self.user is not None:
self.fields['project'].queryset = Projects.objects.filter(user=self.user).order_by('name')
self.fields['subproject'].queryset = SubProjects.objects.filter(user=self.user).order_by('name')
self.fields['context'].queryset = Context.objects.filter(user=self.user).order_by('name')
self.fields['tag'].queryset = Tag.objects.filter(user=self.user).order_by('name')

if self.project_obj is not None and not self.instance.pk:
self.initial['aggregation_type'] = 'project'
self.initial['project'] = self.project_obj

if self.instance.pk:
self.initial['aggregation_type'] = self.instance.aggregation_type

def clean(self):
cleaned_data = super().clean()
min_balance = cleaned_data.get('min_balance')
max_balance = cleaned_data.get('max_balance')

if min_balance is not None and max_balance is not None:
if min_balance > max_balance:
raise forms.ValidationError("Min balance cannot be greater than max balance.")

if min_balance is not None and max_balance is not None and min_balance > max_balance:
raise forms.ValidationError("Min balance cannot be greater than max balance.")
if min_balance is not None and min_balance > 0:
raise forms.ValidationError("Min balance must be zero or negative.")

aggregation_type = cleaned_data.get('aggregation_type') or getattr(self.instance, 'aggregation_type', None)
if not aggregation_type and self.project_obj is not None:
aggregation_type = 'project'
if not aggregation_type:
raise forms.ValidationError('Please select an aggregation type.')

selected_target = cleaned_data.get(aggregation_type)
if selected_target is None and self.instance.pk:
selected_target = getattr(self.instance, aggregation_type, None)
if selected_target is None and aggregation_type == 'project' and self.project_obj is not None:
selected_target = self.project_obj
if selected_target is None:
raise forms.ValidationError('Please select a target for the chosen aggregation type.')

cleaned_data['aggregation_type'] = aggregation_type
for field in ['project', 'subproject', 'context', 'tag']:
cleaned_data[field] = selected_target if field == aggregation_type else None

return cleaned_data


class UpdateCommitmentForm(CommitmentForm):
class Meta(CommitmentForm.Meta):
fields = ['commitment_type', 'period', 'target', 'banking_enabled', 'max_balance', 'min_balance', 'active']
fields = CommitmentForm.Meta.fields + ['active']
widgets = {
**CommitmentForm.Meta.widgets,
'active': forms.CheckboxInput(),
}
labels = {
**CommitmentForm.Meta.labels,
'active': 'Active',
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Generated by Django 5.2.11 on 2026-02-23 00:25

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("core", "0033_sessions_crosses_dst_transition"),
]

operations = [
migrations.AddField(
model_name="commitment",
name="aggregation_type",
field=models.CharField(
choices=[
("project", "Project"),
("subproject", "Subproject"),
("context", "Context"),
("tag", "Tag"),
],
default="project",
max_length=20,
),
),
migrations.AddField(
model_name="commitment",
name="context",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="commitment",
to="core.context",
),
),
migrations.AddField(
model_name="commitment",
name="subproject",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="commitment",
to="core.subprojects",
),
),
migrations.AddField(
model_name="commitment",
name="tag",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="commitment",
to="core.tag",
),
),
migrations.AlterField(
model_name="commitment",
name="project",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="commitment",
to="core.projects",
),
),
]
54 changes: 51 additions & 3 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,14 +283,25 @@ def save(self, *args, **kwargs):
('sessions', 'Session-based'),
)

aggregation_type_choices = (
('project', 'Project'),
('subproject', 'Subproject'),
('context', 'Context'),
('tag', 'Tag'),
)


class Commitment(models.Model):
"""
Optional commitment tracking for projects.
Optional commitment tracking across project aggregations.
Tracks whether users are meeting their time/session goals with time-banking.
"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='commitments')
project = models.OneToOneField(Projects, on_delete=models.CASCADE, related_name='commitment')
aggregation_type = models.CharField(max_length=20, choices=aggregation_type_choices, default='project')
project = models.OneToOneField(Projects, on_delete=models.CASCADE, related_name='commitment', null=True, blank=True)
subproject = models.OneToOneField(SubProjects, on_delete=models.CASCADE, related_name='commitment', null=True, blank=True)
context = models.OneToOneField(Context, on_delete=models.CASCADE, related_name='commitment', null=True, blank=True)
tag = models.OneToOneField(Tag, on_delete=models.CASCADE, related_name='commitment', null=True, blank=True)

commitment_type = models.CharField(max_length=10, choices=commitment_type_choices, default='time')
period = models.CharField(max_length=15, choices=period_choices, default='weekly')
Expand All @@ -312,4 +323,41 @@ class Meta:

def __str__(self):
type_label = 'min' if self.commitment_type == 'time' else 'sessions'
return f"{self.project.name}: {self.target} {type_label}/{self.period}"
return f"{self.target_name}: {self.target} {type_label}/{self.period}"

@property
def target_object(self):
return {
'project': self.project,
'subproject': self.subproject,
'context': self.context,
'tag': self.tag,
}.get(self.aggregation_type)

@property
def target_name(self):
target = self.target_object
return target.name if target else 'Unknown target'

def clean(self):
super().clean()
targets = {
'project': self.project,
'subproject': self.subproject,
'context': self.context,
'tag': self.tag,
}

active_targets = [key for key, value in targets.items() if value is not None]
if len(active_targets) != 1:
raise ValidationError('Exactly one commitment target must be set.')

if self.aggregation_type not in targets:
raise ValidationError('Invalid aggregation type selected.')

if targets.get(self.aggregation_type) is None:
raise ValidationError('Aggregation type must match the selected target.')

target = targets[self.aggregation_type]
if getattr(target, 'user_id', None) != self.user_id:
raise ValidationError('Commitment target must belong to the same user.')
34 changes: 32 additions & 2 deletions core/templates/core/create_commitment.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,41 @@
{% load crispy_forms_tags %}

{% block content %}
<h2>Add Commitment for {{ project.name }}</h2>
<h2>Add Commitment</h2>

<form class="flex-row" method="post" enctype="multipart/form-data">
{% csrf_token %}
<section id="input-fields" class="card stacked half-width">
<span class="label-input">
{{ form.aggregation_type.label_tag }}
{{ form.aggregation_type }}
{{ form.aggregation_type.errors }}
</span>

<span class="label-input">
{{ form.project.label_tag }}
{{ form.project }}
{{ form.project.errors }}
</span>

<span class="label-input">
{{ form.subproject.label_tag }}
{{ form.subproject }}
{{ form.subproject.errors }}
</span>

<span class="label-input">
{{ form.context.label_tag }}
{{ form.context }}
{{ form.context.errors }}
</span>

<span class="label-input">
{{ form.tag.label_tag }}
{{ form.tag }}
{{ form.tag.errors }}
</span>

<span class="label-input">
{{ form.commitment_type.label_tag }}
{{ form.commitment_type }}
Expand Down Expand Up @@ -67,7 +97,7 @@ <h4>Time Banking Options</h4>
</button>

<button type="button" class="secondary-button"
onclick="window.location.href = '{% url 'update_project' project.id %}' ">
onclick="window.location.href = '{% url 'home' %}' ">
<i class="fa fa-arrow-left"></i>
Cancel
</button>
Expand Down
8 changes: 4 additions & 4 deletions core/templates/core/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ <h2 class="section-header collapsible-header" data-target="commitments-body">
{% for item in commitments_data %}
<div class="card" style="background-color: rgba(0, 0, 0, 0.5); border-color: var(--border-dark); padding: 1rem; margin-bottom: 0.75rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
<a href="{% url 'update_project' item.commitment.project.id %}" class="plain-link">
<span class="text-red" style="font-weight: bold; font-size: 1.1em;">{{ item.commitment.project.name }}</span>
</a>
{% if item.commitment.aggregation_type == 'project' and item.commitment.project %}<a href="{% url 'update_project' item.commitment.project.id %}" class="plain-link">{% endif %}
<span class="text-red" style="font-weight: bold; font-size: 1.1em;">{{ item.commitment.target_name }} <small class="text-muted">({{ item.commitment.get_aggregation_type_display }})</small></span>
{% if item.commitment.aggregation_type == 'project' and item.commitment.project %}</a>{% endif %}
<span style="font-size: 0.8em;" class="text-muted">
{{ item.progress.period }}
</span>
Expand Down Expand Up @@ -122,7 +122,7 @@ <h2 class="section-header collapsible-header" data-target="commitments-body">
{% endfor %}
{% else %}
<div class="card">
<p class="text-muted" style="font-size: 0.9em;">No active commitments. <a href="{% url 'projects' %}">Add one</a>.</p>
<p class="text-muted" style="font-size: 0.9em;">No active commitments. <a href="{% url 'create_commitment_generic' %}">Add one</a>.</p>
</div>
{% endif %}
</div>
Expand Down
4 changes: 2 additions & 2 deletions core/templates/core/delete_commitment.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ <h3>Are you sure you want to delete this commitment?</h3>
<table>
<tr>
<td class="text-red">
<h3>{{ commitment.project.name }}</h3>
<h3>{{ commitment.target_name }}</h3>
</td>
</tr>
<tr>
Expand Down Expand Up @@ -50,7 +50,7 @@ <h3>{{ commitment.project.name }}</h3>
</p>

<div class="button-row">
<button type="button" class="secondary-button" onclick="window.location.href='{% url 'update_project' commitment.project.id %}'">
<button type="button" class="secondary-button" onclick="window.location.href='{% url 'home' %}'">
<i class="fa fa-arrow-left"></i>
Back
</button>
Expand Down
34 changes: 32 additions & 2 deletions core/templates/core/update_commitment.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,41 @@
{% load crispy_forms_tags %}

{% block content %}
<h2>Update Commitment for {{ commitment.project.name }}</h2>
<h2>Update Commitment for {{ commitment.target_name }}</h2>

<form class="flex-row" method="post" enctype="multipart/form-data">
{% csrf_token %}
<section id="input-fields" class="card stacked half-width">
<span class="label-input">
{{ form.aggregation_type.label_tag }}
{{ form.aggregation_type }}
{{ form.aggregation_type.errors }}
</span>

<span class="label-input">
{{ form.project.label_tag }}
{{ form.project }}
{{ form.project.errors }}
</span>

<span class="label-input">
{{ form.subproject.label_tag }}
{{ form.subproject }}
{{ form.subproject.errors }}
</span>

<span class="label-input">
{{ form.context.label_tag }}
{{ form.context }}
{{ form.context.errors }}
</span>

<span class="label-input">
{{ form.tag.label_tag }}
{{ form.tag }}
{{ form.tag.errors }}
</span>

<span class="label-input">
{{ form.commitment_type.label_tag }}
{{ form.commitment_type }}
Expand Down Expand Up @@ -82,7 +112,7 @@ <h4>Time Banking Options</h4>
</button>

<button type="button" class="secondary-button"
onclick="window.location.href = '{% url 'update_project' commitment.project.id %}' ">
onclick="window.location.href = '{% url 'home' %}' ">
<i class="fa fa-arrow-left"></i>
Back
</button>
Expand Down
Loading