From 371b0d823a7b21c0b3c9da8d1cb745023c38146f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Moroz?= Date: Sat, 15 Nov 2025 16:48:47 +0100 Subject: [PATCH 1/4] feat: add Discovery model and ViewSet with expanded event data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Discovery event type and REST API endpoint for managing Discovery items with full CRUD operations and expanded event data serialization. Changes: - Add Discovery model with ManyToMany relationship to Events - Create database migration for Discovery model - Add DiscoveryAdmin with event filter_horizontal interface - Create DiscoveryViewSet with full CRUD operations - Add BasicDiscoverySerializer to prevent circular references - Add DiscoverySerializer with nested event expansion - Register discoveries endpoint at /discoveries/ - Add discovery_events API endpoint for filtered event selection The DiscoverySerializer returns full event JSON for each associated event, using write_only event_ids field for input and read_only events field for output with complete event data via EventSerializer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tasks/apps/tree/admin.py | 12 +- tasks/apps/tree/migrations/0062_discovery.py | 28 +++++ tasks/apps/tree/models.py | 18 +++ tasks/apps/tree/serializers.py | 108 +++++++++++++++++ tasks/apps/tree/urls.py | 5 + tasks/apps/tree/views_discovery.py | 116 +++++++++++++++++++ 6 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 tasks/apps/tree/migrations/0062_discovery.py create mode 100644 tasks/apps/tree/views_discovery.py diff --git a/tasks/apps/tree/admin.py b/tasks/apps/tree/admin.py index de8b135..2ae3c06 100644 --- a/tasks/apps/tree/admin.py +++ b/tasks/apps/tree/admin.py @@ -76,6 +76,7 @@ class EventAdmin(PolymorphicParentModelAdmin): ObservationReflectedUpon, ObservationReinterpreted, JournalAdded, + Discovery, ProjectedOutcomeMade, ProjectedOutcomeRedefined, ProjectedOutcomeRescheduled, @@ -114,10 +115,18 @@ class ObservationClosedAdmin(PolymorphicChildModelAdmin): class JournalAddedAdmin(PolymorphicChildModelAdmin): base_model = JournalAdded - + list_display = ('__str__', 'thread', 'published') +class DiscoveryAdmin(PolymorphicChildModelAdmin): + base_model = Discovery + + list_display = ('__str__', 'name', 'thread', 'published') + readonly_fields = ('event_stream_id', 'published') + filter_horizontal = ('events',) + + class ProjectedOutcomeMadeAdmin(PolymorphicChildModelAdmin): base_model = ProjectedOutcomeMade @@ -188,6 +197,7 @@ class JournalTagAdmin(admin.ModelAdmin): admin.site.register(HabitTracked, HabitTrackedAdmin) admin.site.register(ObservationUpdated, ObservationUpdatedAdmin) admin.site.register(JournalAdded, JournalAddedAdmin) +admin.site.register(Discovery, DiscoveryAdmin) admin.site.register(Event, EventAdmin) admin.site.register(QuickNote) admin.site.register(JournalTag, JournalTagAdmin) diff --git a/tasks/apps/tree/migrations/0062_discovery.py b/tasks/apps/tree/migrations/0062_discovery.py new file mode 100644 index 0000000..9ef09dc --- /dev/null +++ b/tasks/apps/tree/migrations/0062_discovery.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.26 on 2025-11-15 11:46 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tree', '0061_add_all_keywords_to_existing_profiles'), + ] + + operations = [ + migrations.CreateModel( + name='Discovery', + fields=[ + ('event_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tree.event')), + ('name', models.CharField(max_length=255)), + ('comment', models.TextField(help_text='Discovery details')), + ('events', models.ManyToManyField(blank=True, related_name='discoveries', to='tree.event')), + ], + options={ + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=('tree.event',), + ), + ] diff --git a/tasks/apps/tree/models.py b/tasks/apps/tree/models.py index 2d0c4b6..927ba53 100644 --- a/tasks/apps/tree/models.py +++ b/tasks/apps/tree/models.py @@ -532,6 +532,24 @@ def update_journal_added_event_stream_id(sender, instance, *args, **kwargs): instance.event_stream_id = journal_added_event_stream_id(instance) +class Discovery(Event): + name = models.CharField(max_length=255) + comment = models.TextField(help_text=_("Discovery details")) + + events = models.ManyToManyField(Event, related_name='discoveries', blank=True) + + template = "tree/events/discovery.html" + + def __str__(self): + return self.name + +@receiver(pre_save, sender=Discovery) +def update_discovery_event_stream_id(sender, instance, *args, **kwargs): + # Generate unique event_stream_id for each Discovery if not already set + if not instance.event_stream_id: + instance.event_stream_id = uuid.uuid4() + + class QuickNote(models.Model): published = models.DateTimeField(default=timezone.now) diff --git a/tasks/apps/tree/serializers.py b/tasks/apps/tree/serializers.py index ed6a47c..9c628f3 100644 --- a/tasks/apps/tree/serializers.py +++ b/tasks/apps/tree/serializers.py @@ -211,6 +211,87 @@ class Meta: model = JournalAdded fields = [ 'id', 'comment', 'published', 'thread', 'tags' ] +class BasicDiscoverySerializer(serializers.ModelSerializer): + """Basic Discovery serializer without nested events to avoid circular references""" + thread = serializers.SlugRelatedField( + queryset=Thread.objects.all(), + slug_field='name' + ) + + class Meta: + model = Discovery + fields = [ 'id', 'name', 'comment', 'published', 'thread', 'event_stream_id' ] + + +class DiscoveryEventsRequestSerializer(serializers.Serializer): + """Serializer for validating discovery events API request""" + + EVENT_TYPE_CHOICES = ['journal', 'observation', 'other'] + + number = serializers.IntegerField( + default=3, + min_value=1, + max_value=100, + help_text="Number of events to return" + ) + + events = serializers.ListField( + child=serializers.IntegerField(allow_null=True, min_value=1), + required=False, + allow_null=True, + help_text="List of event IDs (int) or null for random selection" + ) + + from_datetime = serializers.DateTimeField( + required=False, + allow_null=True, + source='from', + help_text="Start datetime for event range (ISO format)" + ) + + to_datetime = serializers.DateTimeField( + required=False, + allow_null=True, + source='to', + help_text="End datetime for event range (ISO format)" + ) + + type = serializers.ListField( + child=serializers.ChoiceField(choices=EVENT_TYPE_CHOICES), + required=False, + allow_null=True, + help_text="List of event types: journal, observation, other" + ) + + def validate(self, data): + """Cross-field validation""" + from_dt = data.get('from') + to_dt = data.get('to') + number = data.get('number', 3) + events = data.get('events', [None] * number) + + if len(events) != number: + raise serializers.ValidationError({ + 'events': f'Events list length ({len(events)}) must match number parameter ({number})' + }) + + data['events'] = events + + # Set defaults for datetime fields + if from_dt is None: + data['from'] = timezone.now() - timedelta(days=30) + + if to_dt is None: + data['to'] = timezone.now() + + # Validate that from is before to + if data['from'] >= data['to']: + raise serializers.ValidationError({ + 'from': 'Start datetime must be before end datetime' + }) + + return data + class QuickNoteSerializer(serializers.ModelSerializer): class Meta: model = QuickNote @@ -486,6 +567,7 @@ class EventSerializer(PolymorphicSerializer): ObservationAttached: ObservationAttachedSerializer, ObservationDetached: ObservationDetachedSerializer, JournalAdded: JournalAddedSerializer, + Discovery: BasicDiscoverySerializer, HabitTracked: HabitTrackedSerializer, ProjectedOutcomeMade: ProjectedOutcomeMadeSerializer, ProjectedOutcomeRedefined: ProjectedOutcomeRedefinedSerializer, @@ -493,6 +575,32 @@ class EventSerializer(PolymorphicSerializer): ProjectedOutcomeClosed: ProjectedOutcomeClosedSerializer, } + +class DiscoverySerializer(serializers.ModelSerializer): + """Full Discovery serializer with expanded event data""" + thread = serializers.SlugRelatedField( + queryset=Thread.objects.all(), + slug_field='name' + ) + + events = serializers.SerializerMethodField() + event_ids = serializers.PrimaryKeyRelatedField( + many=True, + queryset=Event.objects.all(), + source='events', + write_only=True, + required=False + ) + + def get_events(self, obj): + """Return full event data using EventSerializer""" + return EventSerializer(obj.events.all(), many=True, context=self.context).data + + class Meta: + model = Discovery + fields = [ 'id', 'name', 'comment', 'published', 'thread', 'event_stream_id', 'events', 'event_ids' ] + + class tree_iterator: """Preorder traversal tree iterator""" diff --git a/tasks/apps/tree/urls.py b/tasks/apps/tree/urls.py index 04ee9ac..2094395 100644 --- a/tasks/apps/tree/urls.py +++ b/tasks/apps/tree/urls.py @@ -7,6 +7,7 @@ from . import views_habit from . import views_observation from . import views_breakthrough +from . import views_discovery # API Router - REST Framework ViewSets @@ -28,6 +29,9 @@ router.register(r'updates', views_observation.ObservationUpdatedViewSet) router.register(r'observation-events', views_observation.ObservationEventViewSet) +# Discovery +router.register(r'discoveries', views_discovery.DiscoveryViewSet) + # Habits router.register(r'habit-api', views_habit.HabitViewSet) @@ -90,6 +94,7 @@ path('events/', views.EventCurrentMonthArchiveView.as_view(month_format="%m"), name='public-event-archive-current-month'), path('events///', views.EventArchiveMonthView.as_view(month_format="%m"), name='public-event-archive-month'), path('api/events/daily/', views.daily_events, name='daily-events'), + path('api/events/discovery/', views_discovery.discovery_events, name='discovery-events'), # === Quick Notes (views) === path('q/', views.quick_notes, name='quick-notes'), diff --git a/tasks/apps/tree/views_discovery.py b/tasks/apps/tree/views_discovery.py new file mode 100644 index 0000000..330ef2c --- /dev/null +++ b/tasks/apps/tree/views_discovery.py @@ -0,0 +1,116 @@ +from rest_framework.decorators import api_view +from rest_framework.response import Response as RestResponse +from rest_framework import status, viewsets +from django.utils import timezone +import random + +from .models import Event, JournalAdded, observation_event_types, HabitTracked, Discovery, ProjectedOutcomeMade, ProjectedOutcomeRedefined, ProjectedOutcomeRescheduled, ProjectedOutcomeClosed +from .serializers import EventSerializer, DiscoveryEventsRequestSerializer, DiscoverySerializer + +journal_event_types = [ + JournalAdded +] + +other_event_types = [ + HabitTracked, + Discovery, + ProjectedOutcomeMade, + ProjectedOutcomeRedefined, + ProjectedOutcomeRescheduled, + ProjectedOutcomeClosed +] + +journal_event_type_mapping = { + 'journal': journal_event_types, + 'observation': observation_event_types, + 'other': other_event_types +} + +def filter_events_by_types(qs, event_types): + if not event_types or not isinstance(event_types, list): + return qs + + type_filters = [] + + for event_type in event_types: + type_filters.extend(journal_event_type_mapping[event_type]) + + if not type_filters: + return qs + + return qs.instance_of(*type_filters) + + +@api_view(['POST']) +def discovery_events(request): + + # Validate request data + request_serializer = DiscoveryEventsRequestSerializer(data=request.data) + if not request_serializer.is_valid(): + return RestResponse( + request_serializer.errors, + status=status.HTTP_400_BAD_REQUEST + ) + + # Extract validated data + validated_data = request_serializer.validated_data + number = validated_data.get('number', 3) + events_param = validated_data.get('events') + from_datetime = validated_data.get('from') + to_datetime = validated_data.get('to') + event_types = validated_data.get('type', ['journal', 'observation', 'other']) + + # Build base queryset + queryset = Event.objects.filter( + published__gte=from_datetime, + published__lte=to_datetime + ) + + # Filter by event types if specified + queryset = filter_events_by_types(queryset, event_types) + + locked_event_ids = [x for x in events_param if x is not None] + locked_events_count = len(locked_event_ids) + random_events_count = number - locked_events_count + + random_events = list(queryset.exclude( + id__in=locked_event_ids + ).order_by('?')[:random_events_count]) + + if len(random_events) < random_events_count: + return RestResponse( + {'error': f'Not enough events available. Requested {random_events_count}, found {len(random_events)}'}, + status=status.HTTP_400_BAD_REQUEST + ) + + locked_events = {event.id: event for event in Event.objects.filter(id__in=locked_event_ids)} + + if len(locked_events) < locked_events_count: + return RestResponse( + {'error': f'Some requested events do not exist. Requested {locked_event_ids}, found {len(locked_events)}'}, + status=status.HTTP_400_BAD_REQUEST + ) + + result_events = [] + + for event_id in events_param: + if event_id is None: + result_events.append(random_events.pop(0)) + else: + result_events.append(locked_events[event_id]) + + serializer = EventSerializer(result_events, many=True, context={'request': request}) + + return RestResponse({ + 'count': len(result_events), + 'events': serializer.data + }, status=status.HTTP_200_OK) + + +class DiscoveryViewSet(viewsets.ModelViewSet): + """ + ViewSet for creating, reading, updating, and deleting Discovery items. + Similar to ObservationUpdatedViewSet. + """ + queryset = Discovery.objects.order_by('-published') + serializer_class = DiscoverySerializer From 7cd6d98999cfd746df4c210e75632d0947b2ddef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Moroz?= Date: Fri, 21 Nov 2025 20:20:05 +0100 Subject: [PATCH 2/4] docs: Add guide to adding new event types --- docs/adding-new-event-types.md | 320 +++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 docs/adding-new-event-types.md diff --git a/docs/adding-new-event-types.md b/docs/adding-new-event-types.md new file mode 100644 index 0000000..cc43ff9 --- /dev/null +++ b/docs/adding-new-event-types.md @@ -0,0 +1,320 @@ +# Adding New Event Types to the Application + +This guide describes the step-by-step process for adding new event types to the tasks-collector application. Events are the core of the application's event-sourcing architecture, where all user activities are tracked as polymorphic events with stream IDs. + +## Overview + +The application uses Django Polymorphic to implement an event-sourcing system. All events inherit from the base `Event` model. Each event type can make use of the following facilities: + +- Thread references +- Event streams +- Additional fields defined in child models +- Publication timestamps + +The purpose of each of the facilities is described in the sections below. A step-by-step guide to add a new Event type follows. + +## TL;DR + +1. Decide on event-stream strategy + 1. Thread-based + 2. Entity-based + 3. Unique + +## Thread references + +Some features, such as task boards and journals are using thread references. For these events, it matters whether they were published under a Daily, Weekly, big-picture or any other thread present in the system. + +For example: Weekly summary journal entries will be published with the Weekly thread, making search for these very easy and enabling big-picture (monthly) summaries. + +There is no specific action required to enable this behaviour. By default, threads need to be set on all Events, so if you plan not to use this feature, you can default to one value, such as using `Daily` for all events of the new type. + +## Event streams + +Event streams are unique identifiers marking that the series of events defines one logical entity. + +Before creating a new event type, decide on the event stream ID strategy: + +### Thread-based Stream ID + +Events that belong to a specific thread share the same `event_stream_id` within that thread. These events are considered part of one infinite chronological sequence of events. + +The examples of this are `JournalAdded`, `BoardCommitted`, for which all events under a single thread are considered part of the same chronological sequence of events. + + + +### Entity-based streams + +Some events belong to a stream connected to a logical entity in the system. + +An example might be a domain aggregate constructed from an `Observation` object and `ObservationMade`, `ObservationUpdated`... events. The lifecycle of the Observation object within the aggregate deletes the Observation instance on the occurrence of the `ObservationClosed` domain event. In their closed state, observations keep track of their events by keeping the same `event_stream_id` on all events. + +### Unique / Events that have no external entities + +Some events constitute their own logical entities and should have unique event stream ids generated on their own. + +An example is a `Discovery` event that is a single event constituting a new connection between other events. + +## Step-by-Step Guide + +### 1. Define the Model + +Add your new event model to `tasks/apps/tree/models.py`. The model should: + +- Inherit from `Event` (and optionally other mixins) +- Define all necessary fields +- Include a `template` attribute pointing to the template file + +**Example: Creating a Discovery event** + +```python +class Discovery(Event): + name = models.CharField(max_length=255) + comment = models.TextField(help_text=_("Discovery details")) + + # ManyToMany to link to arbitrary events + events = models.ManyToManyField(Event, related_name='discoveries', blank=True) + + template = "tree/events/discovery.html" + + def __str__(self): + return self.name +``` + +### 2. Add Signal Handler for event_stream_id + +Create a `pre_save` signal handler to set the `event_stream_id` before the event is saved. + +#### Thread-based implementation + +Use a UUID v5 generator based on thread name. An example would be: + +```python +BOARD_URL = 'https://schemas.polybrain.org/tasks/boards/{}' + +def thread_event_stream_id(url, thread): + return uuid.uuid5(uuid.NAMESPACE_URL, name=url.format(slugify(thread.name))) + +def board_event_stream_id(board): + return thread_event_stream_id(BOARD_URL, board.thread) +``` + +#### Entity-based implementation + +The referenced entity typically needs to have `event_stream_id` that can be generated as `uuid.uuid4` random value. Then all the events in this stream would copy this from the referenced object. + +```python +# Entity model +class Observation(models.Model): + # [...] + event_stream_id = models.UUIDField(default=uuid.uuid4, editable=False) + # [...] + +# Event types +@receiver(pre_save) +def copy_observation_to_update_events(sender, instance, *args, **kwargs): + if not isinstance(instance, observation_event_types): + return + + if not instance.thread_id and instance.observation: + instance.thread_id = instance.observation.thread_id + + instance.event_stream_id = instance.observation.event_stream_id +``` + +Another way would be to have the first event in the sequence to have a generated event_stream_id and next events would copy the initially generated one. + +#### Unique event stream implementation + +For such events, you can create a signal that would create a random UUID on creation. + +```python +@receiver(pre_save, sender=Discovery) +def update_discovery_event_stream_id(sender, instance, *args, **kwargs): + # Generate unique event_stream_id for each Discovery if not already set + if not instance.event_stream_id: + instance.event_stream_id = uuid.uuid4() +``` + +### 3. Create Database Migration + +Generate and apply the migration: + +```bash +# Generate migration +docker compose -f docker/development/docker-compose.yml exec tasks-backend python manage.py makemigrations + +# Apply migration +docker compose -f docker/development/docker-compose.yml exec tasks-backend python manage.py migrate +``` + +### 4. Register in Django Admin + +Add the event to `tasks/apps/tree/admin.py`: + +**Create an admin class:** + +```python +class DiscoveryAdmin(PolymorphicChildModelAdmin): + base_model = Discovery + + list_display = ('__str__', 'name', 'thread', 'published') + readonly_fields = ('event_stream_id', 'published') + filter_horizontal = ('events',) # For ManyToMany fields +``` + +**Add to EventAdmin.child_models list:** + +```python +class EventAdmin(PolymorphicParentModelAdmin): + base_model = Event + + child_models = [ + HabitTracked, + BoardCommitted, + # ... other events ... + Discovery, # Add your new event here + ProjectedOutcomeMade, + # ... more events ... + ] +``` + +**Register the admin:** + +```python +admin.site.register(Discovery, DiscoveryAdmin) +``` + +### 5. Create Template (Optional) + +Create a template file referenced in the model's `template` attribute: + +```html + +
+

{{ event.name }}

+

{{ event.comment }}

+ + {% if event.events.exists %} + + {% endif %} +
+``` + +## Common Patterns and Best Practices + +### Foreign Keys to Entities + +When linking to entities that can be deleted, use `SET_NULL`: + +```python +observation = models.ForeignKey( + Observation, + on_delete=models.SET_NULL, + null=True, + blank=True +) +``` + +This preserves the event history even after the entity is deleted. + +### Static Factory Methods + +Provide factory methods to create events from entities: + +```python +@staticmethod +def from_observation(observation, published=None): + return ObservationMade( + published=published or aware_from_date(observation.pub_date), + event_stream_id=observation.event_stream_id, + thread=observation.thread, + type=observation.type, + situation=observation.situation, + interpretation=observation.interpretation, + approach=observation.approach, + ) +``` + +### Mixins for Shared Behavior + +Create mixins for common functionality: + +```python +class ObservationEventMixin: + def url(self): + try: + observation = Observation.objects.get(event_stream_id=self.event_stream_id) + return observation.get_absolute_url() + except Observation.DoesNotExist: + return reverse('public-observation-closed-detail', + kwargs={'event_stream_id': self.event_stream_id}) +``` + +### Automatic Event Creation + +Use `post_save` signals to automatically create events when entities are created or modified: + +```python +@receiver(post_save, sender=ProjectedOutcome) +def create_initial_projected_outcome_made_event(sender, instance, created, **kwargs): + if created: + event = ProjectedOutcomeMade.from_projected_outcome(instance) + event.save() +``` + +## Testing Your Event Type + +After creating a new event type: + +1. **Test in Django Admin:** + - Navigate to the admin interface + - Create a new instance of your event + - Verify all fields save correctly + - Check that `event_stream_id` is set properly + +2. **Test via Django Shell:** + ```python + from tasks.apps.tree.models import Discovery, Thread + + thread = Thread.objects.first() + discovery = Discovery.objects.create( + name="Test Discovery", + comment="This is a test", + thread=thread + ) + + print(f"Event Stream ID: {discovery.event_stream_id}") + print(f"Discovery: {discovery}") + ``` + +3. **Verify in Event List:** + - Check that your event appears in the Event admin list + - Verify it's correctly categorized as the child type + - Test filtering and searching + +## Related Files + +When adding a new event type, you'll typically modify: + +- `tasks/apps/tree/models.py` - Model definition and signals +- `tasks/apps/tree/uuid_generators.py` - UUID generators (if thread-based) +- `tasks/apps/tree/admin.py` - Admin registration +- `tasks/templates/tree/events/*.html` - Event template (optional) +- `tasks/apps/tree/migrations/*.py` - Generated migration files + +## Examples in Codebase + +For reference, examine these existing event types: + +- **Simple event with thread-based ID:** `JournalAdded` (lines 522-532 in models.py) +- **Entity with unique ID and related events:** `Observation` (lines 265-322 in models.py) +- **Event with entity-based ID:** `ObservationMade` (lines 373-402 in models.py) +- **Event with ManyToMany relationships:** `Discovery` (lines 535-550 in models.py) +- **Complex event with change tracking:** `ProjectedOutcomeRedefined` (lines 692-716 in models.py) From 6a031f5026d6be778c812d6b48b52f282f3587d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Moroz?= Date: Fri, 21 Nov 2025 20:49:46 +0100 Subject: [PATCH 3/4] feat: show discoveries on Journal Entries today section --- tasks/apps/tree/serializers.py | 7 +++++++ tasks/apps/tree/utils/datetime.py | 13 ++++++++++++- tasks/apps/tree/views_discovery.py | 2 -- tasks/apps/tree/views_today.py | 21 +++++++++------------ tasks/templates/today.html | 23 +++++++++++++++++++++++ 5 files changed, 51 insertions(+), 15 deletions(-) diff --git a/tasks/apps/tree/serializers.py b/tasks/apps/tree/serializers.py index 9c628f3..4f3d2a1 100644 --- a/tasks/apps/tree/serializers.py +++ b/tasks/apps/tree/serializers.py @@ -16,6 +16,10 @@ from django.contrib.auth import get_user_model from django.conf import settings +from .utils.datetime import ( + make_aware_start, + make_aware_end +) class ThreadSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Thread @@ -284,6 +288,9 @@ def validate(self, data): if to_dt is None: data['to'] = timezone.now() + data['from'] = make_aware_start(data['from'].date()) + data['to'] = make_aware_end(data['to'].date()) + # Validate that from is before to if data['from'] >= data['to']: raise serializers.ValidationError({ diff --git a/tasks/apps/tree/utils/datetime.py b/tasks/apps/tree/utils/datetime.py index df34ffb..afd18d0 100644 --- a/tasks/apps/tree/utils/datetime.py +++ b/tasks/apps/tree/utils/datetime.py @@ -4,8 +4,19 @@ from collections import namedtuple from calendar import monthrange + +def make_aware_start(date): + """Convert a date to timezone-aware datetime at the start of the day""" + return timezone.make_aware(datetime.combine(date, datetime.min.time())) + + def aware_from_date(d): - return timezone.make_aware(datetime.combine(d, datetime.min.time())) + return make_aware_start(d) + + +def make_aware_end(date): + """Convert a date to timezone-aware datetime at the end of the day""" + return timezone.make_aware(datetime.combine(date, datetime.max.time())) DayCount = namedtuple('DayCount', ['date', 'count']) diff --git a/tasks/apps/tree/views_discovery.py b/tasks/apps/tree/views_discovery.py index 330ef2c..5fcab7b 100644 --- a/tasks/apps/tree/views_discovery.py +++ b/tasks/apps/tree/views_discovery.py @@ -1,8 +1,6 @@ from rest_framework.decorators import api_view from rest_framework.response import Response as RestResponse from rest_framework import status, viewsets -from django.utils import timezone -import random from .models import Event, JournalAdded, observation_event_types, HabitTracked, Discovery, ProjectedOutcomeMade, ProjectedOutcomeRedefined, ProjectedOutcomeRescheduled, ProjectedOutcomeClosed from .serializers import EventSerializer, DiscoveryEventsRequestSerializer, DiscoverySerializer diff --git a/tasks/apps/tree/views_today.py b/tasks/apps/tree/views_today.py index fd1699b..22fed7c 100644 --- a/tasks/apps/tree/views_today.py +++ b/tasks/apps/tree/views_today.py @@ -3,7 +3,7 @@ from django.contrib.auth.decorators import login_required from django.utils import timezone -from .models import Plan, Reflection, Habit, HabitTracked, JournalAdded, Thread, Event +from .models import Plan, Reflection, Habit, HabitTracked, JournalAdded, Thread, Event, Discovery from .forms import PlanForm, ReflectionForm from collections import Counter @@ -21,21 +21,12 @@ make_last_day_of_the_month, get_week_period, generate_periods, + make_aware_start, + make_aware_end ) from django.urls import reverse - -def make_aware_start(date): - """Convert a date to timezone-aware datetime at the start of the day""" - return timezone.make_aware(datetime.datetime.combine(date, datetime.datetime.min.time())) - - -def make_aware_end(date): - """Convert a date to timezone-aware datetime at the end of the day""" - return timezone.make_aware(datetime.datetime.combine(date, datetime.datetime.max.time())) - - class Period(ABC): """Base class for time periods (Daily, Weekly, Monthly)""" @@ -430,6 +421,11 @@ def today(request): thread=thread, ).order_by('published') + discoveries = Discovery.objects.filter( + published__range=period.as_tuple(), + thread=thread, + ).prefetch_related('events').order_by('published') + actual_today = timezone.now().date() return render(request, 'today.html', { @@ -454,6 +450,7 @@ def today(request): 'threads': Thread.objects.all(), 'journals': journals, + 'discoveries': discoveries, 'event_calendar': event_calendar(period.start - datetime.timedelta(weeks=52), period.end), 'weekly_summary_calendar': weekly_summary_calendar((period.start - datetime.timedelta(weeks=52)).date(), period.end.date()), 'monthly_summary_calendar': monthly_summary_calendar((period.start - datetime.timedelta(weeks=52)).date(), period.end.date()), diff --git a/tasks/templates/today.html b/tasks/templates/today.html index ed84b62..cb77f07 100644 --- a/tasks/templates/today.html +++ b/tasks/templates/today.html @@ -144,6 +144,29 @@

Journal Entries

  • No journal entries for this month.
  • {% endfor %} + + {% if discoveries %} +

    Discoveries

    +
      + {% for discovery in discoveries %} +
    • +

      {{ discovery.name }}

      + {{ discovery.comment|linebreaks }} +
        + {% for event in discovery.events.all %} +
      • + {% if event.template %} + {% include event.template %} + {% else %} + {{ event }} + {% endif %} +
      • + {% endfor %} +
      +
    • + {% endfor %} +
    + {% endif %} From ba2716197584bb3bae45bc1f54b82584a87e2ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Moroz?= Date: Fri, 21 Nov 2025 21:04:20 +0100 Subject: [PATCH 4/4] docs: update TODO --- TODO.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 0e2d39c..15fab0d 100644 --- a/TODO.md +++ b/TODO.md @@ -151,4 +151,8 @@ - [ ] PostHog - [ ] Emoji working - [x] Preferencje dookola toola habits z API -- [x] Write a modal - these items will drop if you commit? \ No newline at end of file +- [x] Write a modal - these items will drop if you commit? +- [ ] Discovery mode + - [ ] Better display (tasks discoveries - simple accordion) + - [ ] Probably would like to refactor events to show a bit different template box (for example, date) + - [ ] Add a discovery history page