diff --git a/apps/showcase/templates/showcase/showcase.html b/apps/showcase/templates/showcase/showcase.html index 3c3399f..7cb71b0 100644 --- a/apps/showcase/templates/showcase/showcase.html +++ b/apps/showcase/templates/showcase/showcase.html @@ -1,4 +1,4 @@ -{% extends "map.html" %} +{% extends "website/map.html" %} {% load sass_tags %} {% load static %} diff --git a/apps/website/migrations/0013_share_place_share_words_alter_share_timestamp.py b/apps/website/migrations/0013_share_place_share_words_alter_share_timestamp.py new file mode 100644 index 0000000..54a3a27 --- /dev/null +++ b/apps/website/migrations/0013_share_place_share_words_alter_share_timestamp.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.5 on 2023-09-16 19:06 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + ("website", "0012_landscape_enabled_alter_landscape_domain"), + ] + + operations = [ + migrations.AddField( + model_name="share", + name="place", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="shares", + to="website.place", + ), + ), + migrations.AddField( + model_name="share", + name="words", + field=models.ManyToManyField( + blank=True, related_name="shares", to="website.word" + ), + ), + migrations.AlterField( + model_name="share", + name="timestamp", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/apps/website/models.py b/apps/website/models.py index 7421c5c..f22fc84 100644 --- a/apps/website/models.py +++ b/apps/website/models.py @@ -7,6 +7,7 @@ from django.contrib.gis.geos import Point from django.db.models import F, Max, Q, Sum from django.shortcuts import get_object_or_404 +from django.utils import timezone from django.utils.translation import gettext as _ from .fields import UniqueBooleanField @@ -50,16 +51,6 @@ class Meta: abstract = True -class Share(LocationModel): - timestamp = models.DateTimeField(auto_now_add=True) - message = models.TextField(max_length=500) - landscape = models.ForeignKey("Landscape", on_delete=models.CASCADE) - - def __str__(self): - message = textwrap.shorten(self.message, width=20, placeholder="...") - return f"{super().__str__()} [{message}]" - - class LeafletProvider(TitledModel): name = models.CharField( max_length=100, @@ -133,6 +124,25 @@ def __str__(self): return ("🚩 " if not self.visible else "") + self.text +class Share(LocationModel): + timestamp = models.DateTimeField(default=timezone.now) + message = models.TextField(max_length=500) + landscape = models.ForeignKey("Landscape", on_delete=models.CASCADE) + + place = models.ForeignKey( + Place, + related_name="shares", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + words = models.ManyToManyField(Word, related_name="shares", blank=True) + + def __str__(self): + message = textwrap.shorten(self.message, width=20, placeholder="...") + return f"{super().__str__()} [{message}]" + + class WordFrequency(models.Model): word = models.ForeignKey( Word, diff --git a/apps/website/scripts/add_demo_shares.py b/apps/website/scripts/add_demo_shares.py index 485f2ab..8f4930b 100644 --- a/apps/website/scripts/add_demo_shares.py +++ b/apps/website/scripts/add_demo_shares.py @@ -11,7 +11,7 @@ from django.conf import settings from django.contrib.gis.geos import Point -from loguru import logger +from django.utils import timezone from tqdm import tqdm from .. import models @@ -21,12 +21,20 @@ TEXT_ROW_RE = re.compile(r"[A-Za-z]") +def random_timestamp(delta=1440): + minutes = round(random.gauss(0, delta)) + return timezone.now() + timezone.timedelta(minutes=minutes) + + def save_share(location: Point, message: str, landscape: models.Landscape): - defaults = {"location": location, "message": message, "landscape": landscape} - share = models.Share(**defaults) + share = models.Share( + location=location, + message=message, + landscape=landscape, + timestamp=random_timestamp(), + ) share.full_clean() share.save() - logger.debug(f"Created Share: {share}") return share diff --git a/apps/website/signals.py b/apps/website/signals.py index b582435..82dde5e 100644 --- a/apps/website/signals.py +++ b/apps/website/signals.py @@ -38,6 +38,9 @@ def on_share_creation_update_frequencies( .first() ) + instance.place = place + instance.save() + logger.debug( f"Receive share near [{place}], update WordFrequency " f"(message: {instance.message})" @@ -59,6 +62,8 @@ def on_share_creation_update_frequencies( word.full_clean() word.save() + instance.words.add(word) + wf, _ = models.WordFrequency.objects.get_or_create(place=place, word=word) wf.frequency = F("frequency") + 1 wf.save() diff --git a/apps/website/templates/website/history.html b/apps/website/templates/website/history.html new file mode 100644 index 0000000..64fbf11 --- /dev/null +++ b/apps/website/templates/website/history.html @@ -0,0 +1,101 @@ +{% extends "website/map.html" %} + +{% load sass_tags %} +{% load static %} + +{% block main %} +
+
+ +
+
+ {% for date in date_range %} + + {{ date | date:"d M" }} + + {% endfor %} +
+ +
+ {% for time in time_range %} + + {{ time | time }} + + {% endfor %} +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + Aggiorna + +
+ +
+
+ +
+
+
+
+{% endblock %} + +{% block scripts %} + {{ block.super }} + + +{% endblock %} \ No newline at end of file diff --git a/apps/website/templates/map.html b/apps/website/templates/website/map.html similarity index 100% rename from apps/website/templates/map.html rename to apps/website/templates/website/map.html diff --git a/apps/website/templates/share.html b/apps/website/templates/website/share.html similarity index 100% rename from apps/website/templates/share.html rename to apps/website/templates/website/share.html diff --git a/apps/website/urls.py b/apps/website/urls.py index 401d5e8..ca32556 100644 --- a/apps/website/urls.py +++ b/apps/website/urls.py @@ -1,11 +1,29 @@ -from django.urls import path -from django.views.generic import RedirectView +from django.urls import path, register_converter +from django.utils import timezone from . import views + +class DateTimeConverter: + regex = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}" + + @staticmethod + def to_python(value): + time = timezone.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S") + return timezone.make_aware(time) + + @staticmethod + def to_url(value): + return value + + +register_converter(DateTimeConverter, "datetime") + app_name = "website" urlpatterns = [ path("", views.Map.as_view()), path("map/", views.Map.as_view(), name="map"), path("share/", views.Share.as_view(), name="share"), + path("history/", views.HistoryMap.as_view(), name="history"), + path("history//", views.HistoryMap.as_view(), name="history"), ] diff --git a/apps/website/views.py b/apps/website/views.py index 8e49372..8c7b1ad 100644 --- a/apps/website/views.py +++ b/apps/website/views.py @@ -1,7 +1,13 @@ +import datetime as dt +from collections import defaultdict +from typing import Iterable + from django.contrib import messages from django.contrib.gis.geos import Point from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Max, Min from django.shortcuts import get_object_or_404, redirect +from django.utils import timezone from django.utils.translation import gettext as _ from django.views.generic import TemplateView @@ -50,7 +56,7 @@ def get_context_data(self, **kwargs): class Share(LandscapeTemplateView): - template_name = "share.html" + template_name = "website/share.html" def post(self, request): form = forms.ShareForm(request.POST) @@ -82,4 +88,54 @@ def get_context_data(self, **kwargs): class Map(MapTemplateView): - template_name = "map.html" + template_name = "website/map.html" + + +class HistoryMap(MapTemplateView): + template_name = "website/history.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + timestamp = kwargs.get("timestamp", timezone.now()) + counters = { + place: defaultdict(lambda: 0) for place in models.Place.objects.all() + } + + for share in models.Share.objects.filter(timestamp__lt=timestamp): + for word in share.words.all(): + counters[share.place][word] += 1 + + output = [] + for place, counter in counters.items(): + if not counter: + continue + max_value = max(counter.values()) + frequencies = [ + [word.text, count / max_value] for word, count in counter.items() + ] + output.append( + {"coordinates": place.coordinates, "frequencies": frequencies} + ) + + context["places"] = output + + timestamp_range = models.Share.objects.aggregate( + min=Min("timestamp"), max=Max("timestamp") + ) + context["timestamp"] = { + "current": timestamp, + "first_date": (d_min := timestamp_range["min"].date()), + "last_date": (d_max := timestamp_range["max"].date()), + } + context["date_range"] = list(date_range(d_min, d_max)) + context["time_range"] = [dt.time(h, 0) for h in range(24)] + + return context + + +def date_range(first_date: dt.date, last_date: dt.date) -> Iterable[dt.date]: + date = first_date + while date <= last_date: + yield date + date += timezone.timedelta(days=1) diff --git a/makefile b/makefile index 80d2e7a..9ea68c0 100644 --- a/makefile +++ b/makefile @@ -75,12 +75,7 @@ collectstatic: # Django database commands -.PHONY: bootstrap-django clean-django demo migrate migrations secret_key superuser sqlite-bootstrap - -bootstrap-django: clean-django secret_key sqlite-bootstrap migrate superuser - -clean-django: - @rm -rf db.sqlite3 .media .static +.PHONY: demo migrate migrations secret_key superuser demo: @LOGURU_LEVEL=INFO $(django) runscript init_demo @@ -100,13 +95,12 @@ superuser: @echo -e $(bold)Creating superuser account 'admin'$(sgr0) @$(django) createsuperuser --username=admin --email=voci@afor.dev -sqlite-bootstrap: - @echo -e $(bold)Prepare SQLite db with GeoDjango enabled$(sgr0) - # Temporary solution for https://code.djangoproject.com/ticket/32935 - @$(django) shell -c "import django;django.db.connection.cursor().execute('SELECT InitSpatialMetaData(1);')"; +# Database related commands +.PHONY: bootstrap-sqlite db-flush db-demo pg-dump sqlite-reset + +bootstrap-sqlite: sqlite-reset migrate superuser -.PHONY: db-flush db-demo pg-dump db-flush: @echo -e $(bold)Deleting all data from database$(sgr0) @$(django) flush @@ -122,4 +116,10 @@ PG_NAME?=landscapes pg-dump: @mkdir -p .backup/ - @pg_dump -U $(PG_USER) $(PG_NAME) | gzip -9 > .backup/landscapes."$(shell date --iso-8601=seconds)".sql.gz \ No newline at end of file + @pg_dump -U $(PG_USER) $(PG_NAME) | gzip -9 > .backup/landscapes."$(shell date --iso-8601=seconds)".sql.gz + +sqlite-reset: + @echo -e $(bold)Prepare SQLite db with GeoDjango enabled$(sgr0) + @rm -rf db.sqlite3 .media .static + # Temporary solution for https://code.djangoproject.com/ticket/32935 + @$(django) shell -c "import django;django.db.connection.cursor().execute('SELECT InitSpatialMetaData(1);')";