Feat: Initial web version of the habits tracker app

This commit introduces the initial web version of the habits tracker application, built with Django. It provides the core functionality for tracking habits, including user authentication, habit management, and repetition logging.

Features implemented:
- User authentication (login, logout, signup) using django-allauth.
- Core habit management features (CRUD operations for habits).
- A main dashboard that displays a list of habits and their repetitions for the last 5 days.
- A habit details page with a calendar view of repetitions for the current month.
- A management command to send email reminders for habits.
- Functionality to export habit data to a CSV file.
- Unit tests for the core features.

This version addresses the feedback from the code review, including fixing the template structure, implementing repetition logging, and fixing the calendar view.
pull/2220/head
google-labs-jules[bot] 2 weeks ago
parent 5aa8744ef4
commit 3a5bf60a30

5
.gitignore vendored

@ -18,3 +18,8 @@ node_modules
*.sketch *.sketch
crowdin.yml crowdin.yml
kotlin-js-store kotlin-js-store
# Python / Django
db.sqlite3
__pycache__/
*.pyc

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

@ -0,0 +1,6 @@
from django.apps import AppConfig
class HabitsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "habits"

@ -0,0 +1,25 @@
from django.core.management.base import BaseCommand
from django.core.mail import send_mail
from django.utils import timezone
from habits.models import Habit
class Command(BaseCommand):
help = 'Sends habit reminders to users'
def handle(self, *args, **options):
now = timezone.now()
habits_to_remind = Habit.objects.filter(
send_reminder=True,
reminder_time__hour=now.hour,
reminder_time__minute=now.minute
)
for habit in habits_to_remind:
send_mail(
f'Reminder: {habit.name}',
f'This is a reminder to complete your habit: {habit.name}',
'from@example.com',
[habit.user.email],
fail_silently=False,
)
self.stdout.write(self.style.SUCCESS(f'Successfully sent reminder for "{habit.name}" to {habit.user.email}'))

@ -0,0 +1,63 @@
# Generated by Django 5.2.6 on 2025-09-13 17:33
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Habit",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="Repetition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date", models.DateField()),
("value", models.IntegerField(default=1)),
(
"habit",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="habits.habit"
),
),
],
),
]

@ -0,0 +1,23 @@
# Generated by Django 5.2.6 on 2025-09-13 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("habits", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="habit",
name="reminder_time",
field=models.TimeField(blank=True, null=True),
),
migrations.AddField(
model_name="habit",
name="send_reminder",
field=models.BooleanField(default=False),
),
]

@ -0,0 +1,21 @@
from django.db import models
from django.contrib.auth.models import User
class Habit(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
send_reminder = models.BooleanField(default=False)
reminder_time = models.TimeField(null=True, blank=True)
def __str__(self):
return self.name
class Repetition(models.Model):
habit = models.ForeignKey(Habit, on_delete=models.CASCADE)
date = models.DateField()
value = models.IntegerField(default=1)
def __str__(self):
return f"{self.habit.name} - {self.date}"

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block content %}
<h2>Are you sure you want to delete "{{ object.name }}"?</h2>
<form method="post">
{% csrf_token %}
<button type="submit">Confirm Delete</button>
<a href="{% url 'habits:habit_detail' object.pk %}">Cancel</a>
</form>
{% endblock %}

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block content %}
<h2>{{ object.name }}</h2>
<p>{{ object.description }}</p>
<h3>Calendar</h3>
<table>
<thead>
<tr>
<th>Mon</th>
<th>Tue</th>
<th>Wed</th>
<th>Thu</th>
<th>Fri</th>
<th>Sat</th>
<th>Sun</th>
</tr>
</thead>
<tbody>
{% for week in month_days %}
<tr>
{% for day in week %}
<td class="{% if day.month != current_month %}not-current-month{% endif %} {% if day in reps %}has-rep{% endif %}">
{% if day.month == current_month %}
{{ day.day }}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
<a href="{% url 'habits:habit_list' %}">Back to Habits</a>
{% endblock %}

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block content %}
<h2>{% if object %}Edit Habit{% else %}Create Habit{% endif %}</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Save</button>
</form>
{% endblock %}

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% load habit_tags %}
{% block content %}
<h2>My Habits</h2>
<a href="{% url 'habits:habit_create' %}">Create New Habit</a>
<a href="{% url 'habits:export_csv' %}">Export to CSV</a>
<table>
<thead>
<tr>
<th>Habit</th>
{% for day in days %}
<th>{{ day|date:"D" }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for habit, reps in habits_with_reps %}
<tr>
<td>
<a href="{% url 'habits:habit_detail' habit.pk %}">{{ habit.name }}</a>
<a href="{% url 'habits:habit_update' habit.pk %}">Edit</a>
<a href="{% url 'habits:habit_delete' habit.pk %}">Delete</a>
</td>
{% for day in days %}
<td>
<form class="log-repetition-form" method="post" action="{% url 'habits:log_repetition' %}">
{% csrf_token %}
<input type="hidden" name="habit_id" value="{{ habit.pk }}">
<input type="hidden" name="date" value="{{ day|date:'Y-m-d' }}">
<button type="submit">
{% if reps|get_item:day %}
&#10003;
{% else %}
&times;
{% endif %}
</button>
</form>
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

@ -0,0 +1,7 @@
from django import template
register = template.Library()
@register.filter
def get_item(dictionary, key):
return dictionary.get(key)

@ -0,0 +1,43 @@
from django.test import TestCase
from django.contrib.auth.models import User
from .models import Habit, Repetition
from django.urls import reverse
class HabitTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='testuser', password='password')
self.client.login(username='testuser', password='password')
self.habit = Habit.objects.create(user=self.user, name='Test Habit')
self.repetition = Repetition.objects.create(habit=self.habit, date='2025-01-01')
def test_habit_list_view(self):
response = self.client.get(reverse('habits:habit_list'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Test Habit')
def test_habit_detail_view(self):
response = self.client.get(reverse('habits:habit_detail', args=[self.habit.pk]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Test Habit')
def test_habit_create_view(self):
response = self.client.post(reverse('habits:habit_create'), {'name': 'New Habit'})
self.assertEqual(response.status_code, 302)
self.assertTrue(Habit.objects.filter(name='New Habit').exists())
def test_habit_update_view(self):
response = self.client.post(reverse('habits:habit_update', args=[self.habit.pk]), {'name': 'Updated Habit'})
self.assertEqual(response.status_code, 302)
self.habit.refresh_from_db()
self.assertEqual(self.habit.name, 'Updated Habit')
def test_habit_delete_view(self):
response = self.client.post(reverse('habits:habit_delete', args=[self.habit.pk]))
self.assertEqual(response.status_code, 302)
self.assertFalse(Habit.objects.filter(pk=self.habit.pk).exists())
def test_export_csv_view(self):
response = self.client.get(reverse('habits:export_csv'))
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'text/csv')
self.assertIn('Test Habit,2025-01-01,1', response.content.decode())

@ -0,0 +1,14 @@
from django.urls import path
from . import views
app_name = 'habits'
urlpatterns = [
path('', views.HabitListView.as_view(), name='habit_list'),
path('<int:pk>/', views.HabitDetailView.as_view(), name='habit_detail'),
path('create/', views.HabitCreateView.as_view(), name='habit_create'),
path('<int:pk>/update/', views.HabitUpdateView.as_view(), name='habit_update'),
path('<int:pk>/delete/', views.HabitDeleteView.as_view(), name='habit_delete'),
path('export/csv/', views.ExportCSVView.as_view(), name='export_csv'),
path('log/', views.LogRepetitionView.as_view(), name='log_repetition'),
]

@ -0,0 +1,102 @@
from django.shortcuts import render
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView, View
from django.contrib.auth.mixins import LoginRequiredMixin
from .models import Habit
from django.urls import reverse_lazy
import csv
from django.http import HttpResponse
import datetime
from django.utils import timezone
class HabitListView(LoginRequiredMixin, ListView):
model = Habit
template_name = 'habits/habit_list.html'
def get_queryset(self):
return Habit.objects.filter(user=self.request.user)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
today = timezone.now().date()
days = [today - datetime.timedelta(days=i) for i in range(5)]
context['days'] = days
habits_with_reps = []
for habit in context['object_list']:
reps = {rep.date: rep for rep in habit.repetition_set.filter(date__in=days)}
habits_with_reps.append((habit, reps))
context['habits_with_reps'] = habits_with_reps
return context
import calendar
class HabitDetailView(LoginRequiredMixin, DetailView):
model = Habit
template_name = 'habits/habit_detail.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
today = timezone.now().date()
cal = calendar.Calendar()
month_days = cal.monthdatescalendar(today.year, today.month)
reps = {rep.date for rep in self.object.repetition_set.filter(date__year=today.year, date__month=today.month)}
context['month_days'] = month_days
context['reps'] = reps
context['current_month'] = today.month
return context
class HabitCreateView(LoginRequiredMixin, CreateView):
model = Habit
fields = ['name', 'description']
template_name = 'habits/habit_form.html'
success_url = reverse_lazy('habits:habit_list')
def form_valid(self, form):
form.instance.user = self.request.user
return super().form_valid(form)
class HabitUpdateView(LoginRequiredMixin, UpdateView):
model = Habit
fields = ['name', 'description']
template_name = 'habits/habit_form.html'
success_url = reverse_lazy('habits:habit_list')
class HabitDeleteView(LoginRequiredMixin, DeleteView):
model = Habit
template_name = 'habits/habit_confirm_delete.html'
success_url = reverse_lazy('habits:habit_list')
from django.shortcuts import get_object_or_404
from .models import Repetition
class ExportCSVView(LoginRequiredMixin, View):
def get(self, request, *args, **kwargs):
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="habits.csv"'
writer = csv.writer(response)
writer.writerow(['Habit', 'Date', 'Value'])
habits = Habit.objects.filter(user=request.user)
for habit in habits:
for repetition in habit.repetition_set.all():
writer.writerow([habit.name, repetition.date, repetition.value])
return response
class LogRepetitionView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
habit_id = request.POST.get('habit_id')
date_str = request.POST.get('date')
date = datetime.datetime.strptime(date_str, '%Y-%m-%d').date()
habit = get_object_or_404(Habit, pk=habit_id, user=request.user)
rep, created = Repetition.objects.get_or_create(habit=habit, date=date)
if not created:
rep.delete()
return HttpResponse(status=204)

@ -0,0 +1,16 @@
"""
ASGI config for habits_tracker project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "habits_tracker.settings")
application = get_asgi_application()

@ -0,0 +1,140 @@
"""
Django settings for habits_tracker project.
Generated by 'django-admin startproject' using Django 5.2.6.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-ck4e+hvwh86i8b*-v0d0uwn$le-o=qvu)f(ss(%6arverkar8f"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"habits",
# The following apps are required by django-allauth
'allauth',
'allauth.account',
'allauth.socialaccount',
]
AUTHENTICATION_BACKENDS = [
# Needed to login by username in Django admin, regardless of `allauth`
'django.contrib.auth.backends.ModelBackend',
# `allauth` specific authentication methods, such as login by e-mail
'allauth.account.auth_backends.AuthenticationBackend',
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
ROOT_URLCONF = "habits_tracker.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / 'templates'],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "habits_tracker.wsgi.application"
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = "static/"
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
SITE_ID = 1
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

@ -0,0 +1,25 @@
"""
URL configuration for habits_tracker project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")),
path("habits/", include("habits.urls", namespace="habits")),
]

@ -0,0 +1,16 @@
"""
WSGI config for habits_tracker project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "habits_tracker.settings")
application = get_wsgi_application()

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "habits_tracker.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block content %}
<h2>Log In</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Log In</button>
</form>
{% endblock %}

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block content %}
<h2>Sign Up</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Sign Up</button>
</form>
{% endblock %}

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html>
<head>
<title>Habits Tracker</title>
<style>
.has-rep {
background-color: #aaffaa;
}
.not-current-month {
color: #ccc;
}
</style>
</head>
<body>
<nav>
<a href="{% url 'habits:habit_list' %}">Home</a>
{% if user.is_authenticated %}
<a href="{% url 'account_logout' %}">Log Out</a>
{% else %}
<a href="{% url 'account_login' %}">Log In</a>
<a href="{% url 'account_signup' %}">Sign Up</a>
{% endif %}
</nav>
<hr>
{% block content %}
{% endblock %}
<script>
document.querySelectorAll('.log-repetition-form').forEach(form => {
form.addEventListener('submit', e => {
e.preventDefault();
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': formData.get('csrfmiddlewaretoken')
}
}).then(response => {
if (response.status === 204) {
const button = form.querySelector('button');
if (button.innerHTML.includes('✓')) {
button.innerHTML = '×';
} else {
button.innerHTML = '✓';
}
}
});
});
});
</script>
</body>
</html>
Loading…
Cancel
Save