diff --git a/.gitignore b/.gitignore index 8dac68273..2fc3378cc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,8 @@ node_modules *.sketch crowdin.yml kotlin-js-store + +# Python / Django +db.sqlite3 +__pycache__/ +*.pyc diff --git a/habits_tracker/habits/__init__.py b/habits_tracker/habits/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/habits_tracker/habits/admin.py b/habits_tracker/habits/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/habits_tracker/habits/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/habits_tracker/habits/apps.py b/habits_tracker/habits/apps.py new file mode 100644 index 000000000..8a1905717 --- /dev/null +++ b/habits_tracker/habits/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class HabitsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "habits" diff --git a/habits_tracker/habits/management/commands/send_reminders.py b/habits_tracker/habits/management/commands/send_reminders.py new file mode 100644 index 000000000..54f975983 --- /dev/null +++ b/habits_tracker/habits/management/commands/send_reminders.py @@ -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}')) diff --git a/habits_tracker/habits/migrations/0001_initial.py b/habits_tracker/habits/migrations/0001_initial.py new file mode 100644 index 000000000..37c622139 --- /dev/null +++ b/habits_tracker/habits/migrations/0001_initial.py @@ -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" + ), + ), + ], + ), + ] diff --git a/habits_tracker/habits/migrations/0002_habit_reminder_time_habit_send_reminder.py b/habits_tracker/habits/migrations/0002_habit_reminder_time_habit_send_reminder.py new file mode 100644 index 000000000..f6c621501 --- /dev/null +++ b/habits_tracker/habits/migrations/0002_habit_reminder_time_habit_send_reminder.py @@ -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), + ), + ] diff --git a/habits_tracker/habits/migrations/__init__.py b/habits_tracker/habits/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/habits_tracker/habits/models.py b/habits_tracker/habits/models.py new file mode 100644 index 000000000..00efe7ac7 --- /dev/null +++ b/habits_tracker/habits/models.py @@ -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}" diff --git a/habits_tracker/habits/templates/habits/habit_confirm_delete.html b/habits_tracker/habits/templates/habits/habit_confirm_delete.html new file mode 100644 index 000000000..22b2ef2f1 --- /dev/null +++ b/habits_tracker/habits/templates/habits/habit_confirm_delete.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block content %} +

Are you sure you want to delete "{{ object.name }}"?

+
+ {% csrf_token %} + + Cancel +
+{% endblock %} diff --git a/habits_tracker/habits/templates/habits/habit_detail.html b/habits_tracker/habits/templates/habits/habit_detail.html new file mode 100644 index 000000000..ecf4f3a62 --- /dev/null +++ b/habits_tracker/habits/templates/habits/habit_detail.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block content %} +

{{ object.name }}

+

{{ object.description }}

+ +

Calendar

+ + + + + + + + + + + + + + {% for week in month_days %} + + {% for day in week %} + + {% endfor %} + + {% endfor %} + +
MonTueWedThuFriSatSun
+ {% if day.month == current_month %} + {{ day.day }} + {% endif %} +
+ + Back to Habits +{% endblock %} diff --git a/habits_tracker/habits/templates/habits/habit_form.html b/habits_tracker/habits/templates/habits/habit_form.html new file mode 100644 index 000000000..86eb83d73 --- /dev/null +++ b/habits_tracker/habits/templates/habits/habit_form.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block content %} +

{% if object %}Edit Habit{% else %}Create Habit{% endif %}

+
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} diff --git a/habits_tracker/habits/templates/habits/habit_list.html b/habits_tracker/habits/templates/habits/habit_list.html new file mode 100644 index 000000000..2ff35f88c --- /dev/null +++ b/habits_tracker/habits/templates/habits/habit_list.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% load habit_tags %} + +{% block content %} +

My Habits

+ Create New Habit + Export to CSV + + + + + {% for day in days %} + + {% endfor %} + + + + {% for habit, reps in habits_with_reps %} + + + {% for day in days %} + + {% endfor %} + + {% endfor %} + +
Habit{{ day|date:"D" }}
+ {{ habit.name }} + Edit + Delete + +
+ {% csrf_token %} + + + +
+
+{% endblock %} diff --git a/habits_tracker/habits/templatetags/habit_tags.py b/habits_tracker/habits/templatetags/habit_tags.py new file mode 100644 index 000000000..4b04b5c64 --- /dev/null +++ b/habits_tracker/habits/templatetags/habit_tags.py @@ -0,0 +1,7 @@ +from django import template + +register = template.Library() + +@register.filter +def get_item(dictionary, key): + return dictionary.get(key) diff --git a/habits_tracker/habits/tests.py b/habits_tracker/habits/tests.py new file mode 100644 index 000000000..014d555cd --- /dev/null +++ b/habits_tracker/habits/tests.py @@ -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()) diff --git a/habits_tracker/habits/urls.py b/habits_tracker/habits/urls.py new file mode 100644 index 000000000..3c62a7a79 --- /dev/null +++ b/habits_tracker/habits/urls.py @@ -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('/', views.HabitDetailView.as_view(), name='habit_detail'), + path('create/', views.HabitCreateView.as_view(), name='habit_create'), + path('/update/', views.HabitUpdateView.as_view(), name='habit_update'), + path('/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'), +] diff --git a/habits_tracker/habits/views.py b/habits_tracker/habits/views.py new file mode 100644 index 000000000..a4e4d54b0 --- /dev/null +++ b/habits_tracker/habits/views.py @@ -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) diff --git a/habits_tracker/habits_tracker/__init__.py b/habits_tracker/habits_tracker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/habits_tracker/habits_tracker/asgi.py b/habits_tracker/habits_tracker/asgi.py new file mode 100644 index 000000000..7097ebeca --- /dev/null +++ b/habits_tracker/habits_tracker/asgi.py @@ -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() diff --git a/habits_tracker/habits_tracker/settings.py b/habits_tracker/habits_tracker/settings.py new file mode 100644 index 000000000..e369c32e3 --- /dev/null +++ b/habits_tracker/habits_tracker/settings.py @@ -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' diff --git a/habits_tracker/habits_tracker/urls.py b/habits_tracker/habits_tracker/urls.py new file mode 100644 index 000000000..07ec761a8 --- /dev/null +++ b/habits_tracker/habits_tracker/urls.py @@ -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")), +] diff --git a/habits_tracker/habits_tracker/wsgi.py b/habits_tracker/habits_tracker/wsgi.py new file mode 100644 index 000000000..b1a5c65c7 --- /dev/null +++ b/habits_tracker/habits_tracker/wsgi.py @@ -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() diff --git a/habits_tracker/manage.py b/habits_tracker/manage.py new file mode 100755 index 000000000..f71e1130c --- /dev/null +++ b/habits_tracker/manage.py @@ -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() diff --git a/habits_tracker/templates/account/login.html b/habits_tracker/templates/account/login.html new file mode 100644 index 000000000..b7050086b --- /dev/null +++ b/habits_tracker/templates/account/login.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block content %} +

Log In

+
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} diff --git a/habits_tracker/templates/account/signup.html b/habits_tracker/templates/account/signup.html new file mode 100644 index 000000000..afcc7755e --- /dev/null +++ b/habits_tracker/templates/account/signup.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block content %} +

Sign Up

+
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} diff --git a/habits_tracker/templates/base.html b/habits_tracker/templates/base.html new file mode 100644 index 000000000..6eb102623 --- /dev/null +++ b/habits_tracker/templates/base.html @@ -0,0 +1,53 @@ + + + + Habits Tracker + + + + +
+ {% block content %} + {% endblock %} + + + +