├── web ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0021_remove_usersettings_js_entry_form.py │ ├── 0005_rename_notes_note_note.py │ ├── 0011_alter_entry_day.py │ ├── 0013_week_week_date.py │ ├── 0020_usersettings_js_entry_form.py │ ├── 0007_note_year.py │ ├── 0012_rename_note_week.py │ ├── 0016_auto_20211117_1646.py │ ├── 0018_auto_20211117_1908.py │ ├── 0024_usersettings_use_js_btn.py │ ├── 0019_week_unique entry.py │ ├── 0014_entry_week.py │ ├── 0017_auto_20211117_1738.py │ ├── 0003_auto_20210806_1612.py │ ├── 0022_usersettings_view_day_form_and_more.py │ ├── 0010_alter_usersettings_user.py │ ├── 0009_alter_usersettings_user.py │ ├── 0015_auto_20211115_2119.py │ ├── 0004_auto_20210806_1638.py │ ├── 0006_auto_20210808_1819.py │ ├── 0002_auto_20210805_2201.py │ ├── 0008_usersettings.py │ ├── 0001_initial.py │ └── 0023_usermoodcolorsettings_remove_week_unique entry_and_more.py ├── templatetags │ ├── __init__.py │ ├── date_fmt.py │ ├── get_item.py │ └── relative_url.py ├── tests.py ├── templates │ ├── web │ │ ├── graph │ │ │ ├── average.html │ │ │ ├── pie_chart.html │ │ │ ├── scatter.html │ │ │ ├── graph.html │ │ │ └── date_select.html │ │ ├── errors │ │ │ ├── 400.html │ │ │ ├── 403.html │ │ │ ├── 404.html │ │ │ └── 500.html │ │ ├── settings │ │ │ ├── js_btn.html │ │ │ ├── view_forms.html │ │ │ ├── default_view_mode.html │ │ │ ├── language.html │ │ │ ├── mood_colors.html │ │ │ └── settings.html │ │ ├── mood-form │ │ │ ├── standout_data.html │ │ │ ├── entry_posted_toast.html │ │ │ ├── default_btn.html │ │ │ ├── note.html │ │ │ ├── js_btn.html │ │ │ ├── form.html │ │ │ └── entry_list.html │ │ ├── css │ │ │ └── moods.css │ │ ├── calendar │ │ │ └── calendar.html │ │ ├── shared │ │ │ ├── i18n.html │ │ │ └── pagination.html │ │ ├── search │ │ │ └── search.html │ │ └── base.html │ ├── django_registration │ │ ├── registration_closed.html │ │ ├── registration_complete.html │ │ └── registration_form.html │ └── registration │ │ └── login.html ├── static │ ├── favicon.ico │ └── img │ │ ├── loading.gif │ │ ├── transparent.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── apple-touch-icon.png │ │ ├── Icon_External_Link.png │ │ ├── android-chrome-192x192.png │ │ └── android-chrome-512x512.png ├── apps.py ├── query_params.py ├── admin.py ├── mood_colors.py ├── management │ └── commands │ │ ├── clear_django_cache.py │ │ └── generate_random_data.py ├── service │ ├── bar_graph.py │ ├── base_graph.py │ ├── pie_graph.py │ ├── scatter_graph.py │ ├── settings.py │ └── sk.py ├── structs.py ├── context_processors.py ├── serializers.py ├── models.py ├── locale │ ├── en_GB │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── de_DE │ │ └── LC_MESSAGES │ │ └── django.po ├── views.py └── api.py ├── node ├── .prettierrc.json ├── src │ ├── scss │ │ ├── datePicker.scss │ │ ├── stimmungskalender.scss │ │ ├── signin.css │ │ ├── bootstrap.scss │ │ └── main.scss │ └── js │ │ ├── plotly.js │ │ ├── sk.util.js │ │ ├── main.js │ │ ├── sk.theme.js │ │ ├── sk.mood.form.js │ │ ├── sk.calendar.js │ │ └── sk.graph.js ├── package.json └── webpack.config.js ├── assets └── form.png ├── .flake8 ├── scripts ├── node.sh └── quickstart.sh ├── .isort.cfg ├── docs ├── faq.md └── docker.md ├── .dockerignore ├── stimmungskalender ├── asgi.py ├── wsgi.py ├── __init__.py ├── urls.py └── settings.py ├── Makefile ├── docker └── app │ ├── uwsgi.ini │ ├── entrypoint.sh │ └── Dockerfile ├── manage.py ├── _config.yml ├── pyproject.toml ├── README.md ├── docker-compose.yml └── requirements.txt /web/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /node/.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /web/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/tests.py: -------------------------------------------------------------------------------- 1 | # Create your tests here. 2 | -------------------------------------------------------------------------------- /web/templates/web/graph/average.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /assets/form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rain0r/stimmungskalender/HEAD/assets/form.png -------------------------------------------------------------------------------- /node/src/scss/datePicker.scss: -------------------------------------------------------------------------------- 1 | @import "../../node_modules/vanillajs-datepicker/sass/datepicker.scss"; 2 | -------------------------------------------------------------------------------- /web/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rain0r/stimmungskalender/HEAD/web/static/favicon.ico -------------------------------------------------------------------------------- /node/src/js/plotly.js: -------------------------------------------------------------------------------- 1 | const Plotly = require("plotly.js/lib/index-basic"); 2 | 3 | window.Plotly = Plotly; 4 | -------------------------------------------------------------------------------- /web/static/img/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rain0r/stimmungskalender/HEAD/web/static/img/loading.gif -------------------------------------------------------------------------------- /web/static/img/transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rain0r/stimmungskalender/HEAD/web/static/img/transparent.png -------------------------------------------------------------------------------- /web/static/img/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rain0r/stimmungskalender/HEAD/web/static/img/favicon-16x16.png -------------------------------------------------------------------------------- /web/static/img/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rain0r/stimmungskalender/HEAD/web/static/img/favicon-32x32.png -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | # extend-ignore = E203 4 | select = C,E,F,W,B,B950 5 | extend-ignore = E203, E501 6 | -------------------------------------------------------------------------------- /web/static/img/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rain0r/stimmungskalender/HEAD/web/static/img/apple-touch-icon.png -------------------------------------------------------------------------------- /web/static/img/Icon_External_Link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rain0r/stimmungskalender/HEAD/web/static/img/Icon_External_Link.png -------------------------------------------------------------------------------- /web/static/img/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rain0r/stimmungskalender/HEAD/web/static/img/android-chrome-192x192.png -------------------------------------------------------------------------------- /web/static/img/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rain0r/stimmungskalender/HEAD/web/static/img/android-chrome-512x512.png -------------------------------------------------------------------------------- /scripts/node.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd node 4 | npm install --audit=false --fund=false --loglevel=error --progress=false 5 | npm run build 6 | cd .. 7 | -------------------------------------------------------------------------------- /web/templates/django_registration/registration_closed.html: -------------------------------------------------------------------------------- 1 | {% extends 'web/base.html' %} 2 | {% block content %}Registration closed.{% endblock %} 3 | -------------------------------------------------------------------------------- /web/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class WebConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "web" 7 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | multi_line_output = 3 3 | include_trailing_comma = True 4 | force_grid_wrap = 0 5 | use_parentheses = True 6 | ensure_newline_before_comments = True 7 | line_length = 88 8 | -------------------------------------------------------------------------------- /web/query_params.py: -------------------------------------------------------------------------------- 1 | # Use as query param names 2 | QP_START_DT = "start_dt" 3 | QP_END_DT = "end_dt" 4 | QP_MOOD = "mood" 5 | QP_SEARCH_TERM = "search_term" 6 | QP_PAGE = "page" 7 | QP_PERIOD = "period" 8 | -------------------------------------------------------------------------------- /web/templates/web/errors/400.html: -------------------------------------------------------------------------------- 1 | {% extends 'web/base.html' %} 2 | {% block content %} 3 |
4 | 400 5 |
6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /web/templates/web/errors/403.html: -------------------------------------------------------------------------------- 1 | {% extends 'web/base.html' %} 2 | {% block content %} 3 |
4 | 403 5 |
6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /web/templates/web/errors/404.html: -------------------------------------------------------------------------------- 1 | {% extends 'web/base.html' %} 2 | {% block content %} 3 |
4 | 404 5 |
6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /web/templatetags/date_fmt.py: -------------------------------------------------------------------------------- 1 | from datetime import date, timedelta 2 | 3 | from django.template.defaulttags import register 4 | 5 | 6 | @register.filter 7 | def week_end_date(date_str: date) -> date: 8 | return date_str + timedelta(days=7) 9 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## How can I add new users? 4 | 5 | To add a new user, please visit the admin site under `/admin/`. 6 | 7 | Let's say your installation runs on `http://localhost:8080`, the admin site can be found under `http://localhost:8080/admin/`. 8 | -------------------------------------------------------------------------------- /web/templates/django_registration/registration_complete.html: -------------------------------------------------------------------------------- 1 | {% extends 'web/base.html' %} 2 | {% block content %} 3 | Registration complete. 4 |

5 | Start 6 |

7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /web/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from web.models import Entry, UserMoodColorSettings, UserSettings, Week 4 | 5 | admin.site.register(Entry) 6 | admin.site.register(Week) 7 | admin.site.register(UserSettings) 8 | admin.site.register(UserMoodColorSettings) 9 | -------------------------------------------------------------------------------- /web/mood_colors.py: -------------------------------------------------------------------------------- 1 | from web.models import Moods 2 | 3 | DEFAULT_COLORS = { 4 | Moods.VERY_BAD: "#dc3545", 5 | Moods.BAD: "#ffc107", 6 | Moods.MEDIUM: "#b2beb5", 7 | Moods.GOOD: "rgba(40,167,69, 0.7)", 8 | Moods.VERY_GOOD: "rgba(40,167,69, 1)", 9 | } 10 | -------------------------------------------------------------------------------- /web/management/commands/clear_django_cache.py: -------------------------------------------------------------------------------- 1 | from django.core.cache import cache 2 | from django.core.management import BaseCommand 3 | 4 | 5 | class Command(BaseCommand): 6 | def handle(self, *args: tuple, **options: dict) -> None: 7 | cache.clear() 8 | print("Cache cleared!") 9 | -------------------------------------------------------------------------------- /web/templates/web/errors/500.html: -------------------------------------------------------------------------------- 1 | {% extends 'web/base.html' %} 2 | {% block content %} 3 |
4 | Error 500 5 |
6 |
{{ type_ }}
7 | {% if exception %}
{{ exception }}
{% endif %} 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /node/src/scss/stimmungskalender.scss: -------------------------------------------------------------------------------- 1 | .clickable { 2 | cursor: pointer; 3 | } 4 | 5 | .color-box { 6 | display: block; 7 | width: 20px; 8 | height: 20px; 9 | } 10 | 11 | .page-title-div { 12 | @extend .mb-5; 13 | @extend .border-bottom; 14 | } 15 | 16 | .page-title { 17 | @extend .fs-1; 18 | @extend .text-decoration-none; 19 | @extend .text-primary; 20 | @extend .text-body; 21 | } 22 | -------------------------------------------------------------------------------- /web/templates/web/settings/js_btn.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |

{% translate 'use_js_btn' %}

3 |
4 | 9 | 10 |
11 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Common 2 | .dockerignore 3 | *.md 4 | docker-compose.yml 5 | _config.yml 6 | db.sqlite3 7 | docs 8 | .flake8 9 | .github 10 | .isort.cfg 11 | 12 | 13 | # git 14 | .git 15 | .gitattributes 16 | .gitignore 17 | 18 | # JetBrains 19 | .idea 20 | 21 | # Node 22 | *.log 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | **/node_modules/ 27 | **/dist 28 | 29 | # python 30 | .venv 31 | .virtualenv 32 | **/__pycache__/ 33 | **/*.py[cod] 34 | -------------------------------------------------------------------------------- /web/migrations/0021_remove_usersettings_js_entry_form.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.2 on 2022-02-18 21:06 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0020_usersettings_js_entry_form"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="usersettings", 14 | name="js_entry_form", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /web/migrations/0005_rename_notes_note_note.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2021-08-06 14:40 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0004_auto_20210806_1638"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RenameField( 13 | model_name="note", 14 | old_name="notes", 15 | new_name="note", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /web/migrations/0011_alter_entry_day.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-09 09:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0010_alter_usersettings_user"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="entry", 14 | name="day", 15 | field=models.DateField(db_index=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /web/migrations/0013_week_week_date.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-15 19:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0012_rename_note_week"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="week", 14 | name="week_date", 15 | field=models.DateField(db_index=True, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /stimmungskalender/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for stimmungskalender project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "stimmungskalender.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /stimmungskalender/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for stimmungskalender project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "stimmungskalender.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /web/templatetags/get_item.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.template.defaulttags import register 4 | 5 | 6 | @register.filter 7 | def get_item(dictionary: dict, key: str) -> Any: 8 | try: 9 | return dictionary.get(key) 10 | except AttributeError: 11 | return None 12 | 13 | 14 | @register.filter 15 | def get_obj_attr(obj: object, attr: str) -> Any: 16 | try: 17 | return getattr(obj, attr) 18 | except AttributeError: 19 | return None 20 | -------------------------------------------------------------------------------- /web/migrations/0020_usersettings_js_entry_form.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-25 18:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0019_week_unique entry"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="usersettings", 14 | name="js_entry_form", 15 | field=models.BooleanField(default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /web/templates/web/graph/pie_chart.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
3 |
4 | {% translate 'time_range' %}: {{ start_dt|date:"SHORT_DATE_FORMAT" }} — {{ end_dt|date:"SHORT_DATE_FORMAT" }} 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /web/migrations/0007_note_year.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2021-08-09 19:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0006_auto_20210808_1819"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="note", 14 | name="year", 15 | field=models.IntegerField(default=2021), 16 | preserve_default=False, 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /stimmungskalender/__init__.py: -------------------------------------------------------------------------------- 1 | def listed_tuples(config_val): 2 | """Transforms a value from the .env file to a list of tuples. 3 | For example: 4 | ADMINS="foo:foo@example1.com,bar:bar@example2.com" 5 | becomes [('foo', 'foo@example1.com'), ('bar', 'bar@example2.com')] 6 | """ 7 | ret = [] 8 | try: 9 | for line in config_val: 10 | admin = str(line).split(":") 11 | ret.append((admin[0], admin[1])) 12 | except AttributeError: 13 | pass 14 | return ret 15 | -------------------------------------------------------------------------------- /web/migrations/0012_rename_note_week.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-15 19:25 2 | 3 | from django.conf import settings 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 10 | ("web", "0011_alter_entry_day"), 11 | ] 12 | 13 | operations = [ 14 | migrations.RenameModel( 15 | old_name="Note", 16 | new_name="Week", 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /web/migrations/0016_auto_20211117_1646.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-17 15:46 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0015_auto_20211115_2119"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="week", 14 | name="week", 15 | ), 16 | migrations.RemoveField( 17 | model_name="week", 18 | name="year", 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /web/migrations/0018_auto_20211117_1908.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-17 18:08 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0017_auto_20211117_1738"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="week", 14 | name="week", 15 | ), 16 | migrations.RemoveField( 17 | model_name="week", 18 | name="year", 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /web/migrations/0024_usersettings_use_js_btn.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-01 20:01 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0023_usermoodcolorsettings_remove_week_unique entry_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="usersettings", 14 | name="use_js_btn", 15 | field=models.BooleanField(default=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /web/migrations/0019_week_unique entry.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-17 18:13 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0018_auto_20211117_1908"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddConstraint( 13 | model_name="week", 14 | constraint=models.UniqueConstraint( 15 | fields=("user", "week_date"), name="unique entry" 16 | ), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /web/templates/web/mood-form/standout_data.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
3 | {% for key in standout_data %} 4 | {% if key.entry.day %} 5 |
6 | 10 |
11 | {% endif %} 12 | {% endfor %} 13 |
14 | -------------------------------------------------------------------------------- /web/templates/web/mood-form/entry_posted_toast.html: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /web/migrations/0014_entry_week.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-15 20:08 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("web", "0013_week_week_date"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="entry", 15 | name="week", 16 | field=models.ForeignKey( 17 | null=True, on_delete=django.db.models.deletion.CASCADE, to="web.week" 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /web/templatetags/relative_url.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.simple_tag 7 | def relative_url(value: str, field_name: str, url_encode: str = "") -> str: 8 | url = "?{}={}".format(field_name, value) 9 | if url_encode: 10 | querystring = url_encode.split("&") 11 | filtered_querystring = filter( 12 | lambda p: p.split("=")[0] != field_name, querystring 13 | ) 14 | encoded_querystring = "&".join(filtered_querystring) 15 | url = "{}&{}".format(url, encoded_querystring) 16 | return url 17 | -------------------------------------------------------------------------------- /web/templates/web/css/moods.css: -------------------------------------------------------------------------------- 1 | /* Very Bad: */ 2 | .mood-1 { 3 | background: {{mood_colors|get_item:1}} !important; 4 | opacity: 70% 5 | } 6 | /* Bad */ 7 | .mood-2 { 8 | background: {{mood_colors|get_item:2}} !important; 9 | opacity: 70% 10 | } 11 | /* Medium */ 12 | .mood-3 { 13 | background: {{mood_colors|get_item:3}} !important; 14 | opacity: 70% 15 | } 16 | /* Good */ 17 | .mood-4 { 18 | background: {{mood_colors|get_item:4}} !important; 19 | opacity: 70% 20 | } 21 | /* Very Good */ 22 | .mood-5 { 23 | background: {{mood_colors|get_item:5}} !important; 24 | opacity: 70% 25 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | container_name = sk-app 2 | 3 | build: 4 | docker compose build 5 | up: 6 | docker compose up 7 | down: 8 | docker compose down 9 | reset-volumes: 10 | docker compose down --volumes 11 | run: build up 12 | reset: reset-volumes run 13 | app-shell: 14 | docker exec -it $(container_name) sh 15 | django-shell: 16 | docker exec -it $(container_name) ./.venv/bin/python ./manage.py shell 17 | first-run: 18 | docker compose run app first_run 19 | requirements: 20 | ./.venv/bin/poetry export --without-hashes --format=requirements.txt --only=main > requirements.txt 21 | format: 22 | ./.venv/bin/isort . && ./.venv/bin/black . 23 | -------------------------------------------------------------------------------- /web/migrations/0017_auto_20211117_1738.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-17 16:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0016_auto_20211117_1646"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="week", 14 | name="week", 15 | field=models.IntegerField(blank=True, null=True), 16 | ), 17 | migrations.AddField( 18 | model_name="week", 19 | name="year", 20 | field=models.IntegerField(blank=True, null=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /web/templates/web/calendar/calendar.html: -------------------------------------------------------------------------------- 1 | {% extends 'web/base.html' %} 2 | {% load i18n %} 3 | {% block title %} 4 | {% translate 'calendar' %} 5 | {% endblock %} 6 | {% block content %} 7 |
8 | {% translate 'calendar' %} 9 |
10 |
11 | 17 | {% endblock %} 18 | {% block js %}{% endblock %} 19 | -------------------------------------------------------------------------------- /docker/app/uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | env LANG = "en_US.utf8" 3 | env LC_ALL = "en_US.UTF-8" 4 | env LC_LANG = "en_US.UTF-8" 5 | 6 | chdir = /srv/www/stimmungskalender 7 | gid = www-data 8 | http-socket = 0.0.0.0:8000 9 | log-4xx = true 10 | log-5xx = true 11 | log-date = [%%Y:%%m:%%d %%H:%%M:%%S] 12 | log-format = %(addr) - %(user) [%(ltime)] "%(method) %(uri) %(proto)" %(status) %(size) "%(referer)" "%(uagent)" 13 | logto = /logs/stimmungskalender.log 14 | plugins = python3, logfile 15 | project = stimmungskalender 16 | uid = www-data 17 | virtualenv = /srv/www/stimmungskalender/.venv 18 | wsgi-file = stimmungskalender/wsgi.py 19 | 20 | static-map = /static=/srv/www/stimmungskalender/static -------------------------------------------------------------------------------- /node/src/scss/signin.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | 6 | body { 7 | display: flex; 8 | align-items: center; 9 | padding-top: 40px; 10 | padding-bottom: 40px; 11 | background-color: #f5f5f5; 12 | } 13 | 14 | .form-signin { 15 | max-width: 330px; 16 | padding: 15px; 17 | } 18 | 19 | .form-signin .form-floating:focus-within { 20 | z-index: 2; 21 | } 22 | 23 | .form-signin input[type="email"] { 24 | margin-bottom: -1px; 25 | border-bottom-right-radius: 0; 26 | border-bottom-left-radius: 0; 27 | } 28 | 29 | .form-signin input[type="password"] { 30 | margin-bottom: 10px; 31 | border-top-left-radius: 0; 32 | border-top-right-radius: 0; 33 | } 34 | -------------------------------------------------------------------------------- /web/migrations/0003_auto_20210806_1612.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2021-08-06 14:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0002_auto_20210805_2201"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="entry", 14 | name="notes", 15 | field=models.TextField(default=""), 16 | preserve_default=False, 17 | ), 18 | migrations.AlterField( 19 | model_name="entry", 20 | name="day", 21 | field=models.DateField(unique=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /web/templates/web/mood-form/default_btn.html: -------------------------------------------------------------------------------- 1 | {% for number in moods reversed %} 2 | {% with mood_value=weekday_entry|get_obj_attr:form.attr %} 3 | 15 | {% endwith %} 16 | {% endfor %} 17 | -------------------------------------------------------------------------------- /web/migrations/0022_usersettings_view_day_form_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.2 on 2022-03-01 20:26 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0021_remove_usersettings_js_entry_form"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="usersettings", 14 | name="view_day_form", 15 | field=models.BooleanField(default=True), 16 | ), 17 | migrations.AddField( 18 | model_name="usersettings", 19 | name="view_night_form", 20 | field=models.BooleanField(default=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /web/migrations/0010_alter_usersettings_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-07 18:37 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("web", "0009_alter_usersettings_user"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="usersettings", 17 | name="user", 18 | field=models.OneToOneField( 19 | on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "stimmungskalender.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /web/templates/web/shared/i18n.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 22 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | ########################################################### 2 | ### Welcome to Beautiful Jekyll! 3 | ### This config file is meant for settings that affect your entire website. When you first 4 | ### set up your website you should go through all these settings and edit them, but after 5 | ### the initial set up you won't need to come back to this file often. 6 | ########################################################### 7 | 8 | ############################ 9 | # --- Required options --- # 10 | ############################ 11 | 12 | # Name of website 13 | title: Stimmungskalender 14 | 15 | # Your name to show in the footer 16 | author: rain0r 17 | 18 | # Output options (more information on Jekyll's site) 19 | timezone: "Europe/Berlin" 20 | markdown: GFM 21 | highlighter: rouge 22 | -------------------------------------------------------------------------------- /web/migrations/0009_alter_usersettings_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-07 18:34 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("web", "0008_usersettings"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="usersettings", 17 | name="user", 18 | field=models.ForeignKey( 19 | on_delete=django.db.models.deletion.CASCADE, 20 | to=settings.AUTH_USER_MODEL, 21 | unique=True, 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /web/templates/web/settings/view_forms.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |

{% translate 'view_forms' %}

3 |
4 | 9 | 10 |
11 |
12 | 17 | 18 |
19 | -------------------------------------------------------------------------------- /node/src/scss/bootstrap.scss: -------------------------------------------------------------------------------- 1 | // Import all of Bootstrap's CSS 2 | @import "~bootstrap/scss/bootstrap"; 3 | 4 | // @import "bootstrap/scss/functions"; 5 | // @import "bootstrap/scss/variables"; 6 | // @import "bootstrap/scss/maps"; 7 | // @import "bootstrap/scss/mixins"; 8 | // @import "bootstrap/scss/utilities"; 9 | 10 | $utilities: map-merge( 11 | $utilities, 12 | ( 13 | "border": 14 | map-merge( 15 | map-get($utilities, "border"), 16 | ( 17 | responsive: true, 18 | ) 19 | ), 20 | ) 21 | ); 22 | 23 | $utilities: map-merge( 24 | $utilities, 25 | ( 26 | "width": 27 | map-merge( 28 | map-get($utilities, "width"), 29 | ( 30 | responsive: true, 31 | ) 32 | ), 33 | ) 34 | ); 35 | 36 | @import "bootstrap/scss/utilities/api"; 37 | -------------------------------------------------------------------------------- /web/migrations/0015_auto_20211115_2119.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-15 20:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0014_entry_week"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="week", 14 | name="note", 15 | field=models.TextField(blank=True), 16 | ), 17 | migrations.AlterField( 18 | model_name="week", 19 | name="week", 20 | field=models.IntegerField(blank=True, null=True), 21 | ), 22 | migrations.AlterField( 23 | model_name="week", 24 | name="year", 25 | field=models.IntegerField(blank=True, null=True), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /web/templates/django_registration/registration_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'web/base.html' %} 2 | {% block content %} 3 |
4 | {% csrf_token %} 5 | stimmungskalender 6 |
7 |

Registration

8 | 9 | {{ form.as_table }} 10 |
11 | 12 | Login 13 | 16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /web/templates/web/settings/default_view_mode.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |

{% translate 'default_view_mode' %}

3 |
4 | 10 | 11 |
12 |
13 | 19 | 20 |
21 | -------------------------------------------------------------------------------- /web/service/bar_graph.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from django.contrib.auth.models import User 4 | from django.db.models import Avg 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from web.service.base_graph import BaseGraph 8 | from web.structs import BarChartResponse 9 | 10 | 11 | class BarGraphService(BaseGraph): 12 | def __init__(self, user: User, mood_mapping: dict, start_dt: date, end_dt: date): 13 | super().__init__(start_dt=start_dt, end_dt=end_dt) 14 | self.user = user 15 | self.mood_mapping = mood_mapping 16 | 17 | def load_data(self) -> BarChartResponse: 18 | qs = self.date_range_qs().aggregate(Avg("mood_day"), Avg("mood_night")) 19 | labels = [str(_(x)) for x in ["day", "night"]] 20 | ret = BarChartResponse( 21 | labels=labels, 22 | values=[qs["mood_day__avg"], qs["mood_night__avg"]], 23 | ) 24 | print(ret) 25 | return ret 26 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "stimmungskalender" 3 | version = "1.8.0" 4 | description = "Self-hosted mood calendar" 5 | authors = ["Rainer Hihn "] 6 | license = "AGPL-3.0-only" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.10" 10 | Django = "^5.1.5" 11 | dj-database-url = "^2.3.0" 12 | django-cors-headers = "^4.6.0" 13 | django-registration = "^5.1.0" 14 | django-rosetta = "^0.10.1" 15 | djangorestframework = "^3.15.2" 16 | python-decouple = "^3.8" 17 | psycopg2 = "^2.9.10" 18 | djangorestframework-dataclasses = "^1.3.1" 19 | pyyaml = "^6.0.2" 20 | inflection = "^0.5.1" 21 | uritemplate = "^4.1.1" 22 | 23 | [tool.poetry.group.dev.dependencies] 24 | black = "^24.10.0" 25 | ipdb = "^0.13.13" 26 | setuptools = "^75.8.0" 27 | isort = "^5.13.2" 28 | 29 | [build-system] 30 | requires = ["poetry-core>=1.0.0"] 31 | build-backend = "poetry.core.masonry.api" 32 | 33 | [tool.isort] 34 | profile = "black" 35 | 36 | [tool.djlint] 37 | profile = "django" 38 | -------------------------------------------------------------------------------- /web/migrations/0004_auto_20210806_1638.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2021-08-06 14:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0003_auto_20210806_1612"), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Note", 14 | fields=[ 15 | ( 16 | "id", 17 | models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("week", models.IntegerField()), 25 | ("notes", models.TextField()), 26 | ], 27 | ), 28 | migrations.RemoveField( 29 | model_name="entry", 30 | name="notes", 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /node/src/js/sk.util.js: -------------------------------------------------------------------------------- 1 | import Datepicker from "vanillajs-datepicker/Datepicker"; 2 | import { Tooltip } from "bootstrap"; 3 | 4 | export const enableDatePicker = function () { 5 | const datePickerTriggerList = document.querySelectorAll( 6 | '[data-provide="datepicker"]' 7 | ); 8 | const options = { 9 | format: "yyyy-mm-dd", 10 | weekStart: 1, 11 | }; 12 | [...datePickerTriggerList].map( 13 | (tooltipTriggerEl) => new Datepicker(tooltipTriggerEl, options) 14 | ); 15 | }; 16 | 17 | export const enableToolTip = function () { 18 | const tooltipTriggerList = document.querySelectorAll( 19 | '[data-bs-toggle="tooltip"]' 20 | ); 21 | [...tooltipTriggerList].map( 22 | (tooltipTriggerEl) => new Tooltip(tooltipTriggerEl) 23 | ); 24 | }; 25 | 26 | export const enableToast = function () { 27 | // const toastElList = document.querySelectorAll(".toast"); 28 | // const hugo = [...toastElList].map((toastEl) => new Toast(toastEl)); 29 | // hugo.forEach((i) => i.show()); 30 | }; 31 | -------------------------------------------------------------------------------- /web/templates/web/mood-form/note.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% if mood_table.week.note %} 3 | 4 |
{{ mood_table.week.note }} 6 |
7 | 8 | {% endif %} 9 |
13 | {% csrf_token %} 14 |
15 | 18 |
19 | 24 |
25 | 26 |
27 | -------------------------------------------------------------------------------- /scripts/quickstart.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | full_path=$(realpath $0) 6 | dir_path=$(dirname $full_path) 7 | 8 | # Use the developer settings for a quick start 9 | cp .env.sample .env 10 | 11 | # Create a virtualenv that holds all dependencies 12 | python3 -m venv --clear virtualenv 13 | 14 | # We don't need postgres support for now, so let's comment it out 15 | sed -i 's/psycopg2/#psycopg2/' pyproject.toml 16 | 17 | # Install the dependencies 18 | ./virtualenv/bin/pip install . 19 | 20 | # Initialize the sqlite database 21 | ./virtualenv/bin/python manage.py migrate 22 | 23 | # Create the frontend texts 24 | if [ ! -f "${dir_path}/../web/locale/de_DE/LC_MESSAGES/django.mo" ]; then 25 | ./virtualenv/bin/django-admin compilemessages 26 | fi 27 | 28 | # Create an user account 29 | ./virtualenv/bin/python manage.py createsuperuser 30 | 31 | # Generate javascript and css files 32 | if [ ! -f "${dir_path}/../web/static/js/skBase.js" ]; then 33 | ./scripts/node.sh 34 | fi 35 | 36 | # Start the app 37 | ./virtualenv/bin/python manage.py runserver 38 | -------------------------------------------------------------------------------- /web/migrations/0006_auto_20210808_1819.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2021-08-08 16:19 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("web", "0005_rename_notes_note_note"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="entry", 17 | name="user", 18 | field=models.ForeignKey( 19 | default=1, on_delete=django.db.models.deletion.CASCADE, to="auth.user" 20 | ), 21 | preserve_default=False, 22 | ), 23 | migrations.AddField( 24 | model_name="note", 25 | name="user", 26 | field=models.ForeignKey( 27 | default=1, on_delete=django.db.models.deletion.CASCADE, to="auth.user" 28 | ), 29 | preserve_default=False, 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /web/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'web/base.html' %} 2 | {% block content %} 3 | {% load i18n %} 4 |
5 |
8 | 11 | {% csrf_token %} 12 |
13 | stimmungs- 14 | kalender 15 |
16 | 17 | {{ form.as_table }} 18 |
19 | 20 |
21 | Register 22 |
23 |
24 |
25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /web/templates/web/graph/scatter.html: -------------------------------------------------------------------------------- 1 | {% load relative_url %} 2 | {% load i18n %} 3 |
4 |
5 | {% translate 'time_range' %}: {{ start_dt|date:"SHORT_DATE_FORMAT" }} — {{ end_dt|date:"SHORT_DATE_FORMAT" }} 6 |
7 | {% with params=request.GET.urlencode %} 8 | 22 | {% endwith %} 23 |
24 |
25 |
26 | -------------------------------------------------------------------------------- /web/service/base_graph.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from datetime import date 3 | 4 | from django.db.models import QuerySet 5 | from django.utils import timezone 6 | 7 | from web.models import Entry 8 | 9 | PERIOD_DAY = "mood_day" 10 | PERIOD_NIGHT = "mood_night" 11 | PERIODS = [PERIOD_DAY, PERIOD_NIGHT] 12 | 13 | 14 | class BaseGraph(ABC): 15 | def __init__(self, start_dt: date, end_dt: date, **kwargs: dict): 16 | self.user = None 17 | self.start_dt = start_dt 18 | self.end_dt = end_dt 19 | 20 | def build_day_range(self, start_dt: date, end_dt: date) -> int: 21 | if start_dt: 22 | return (end_dt - start_dt).days 23 | else: 24 | entry = Entry.objects.filter(user=self.user).first() 25 | days = (entry.day - timezone.now().date()).days 26 | return abs(days) 27 | 28 | def date_range_qs(self) -> QuerySet: 29 | qs = Entry.objects.filter(user=self.user) 30 | if self.start_dt: 31 | qs = qs.filter(day__gte=self.start_dt) 32 | if self.end_dt: 33 | qs = qs.filter(day__lte=self.end_dt) 34 | return qs 35 | -------------------------------------------------------------------------------- /web/templates/web/mood-form/js_btn.html: -------------------------------------------------------------------------------- 1 |
4 | {% for number in moods reversed %} 5 | {% with mood_value=weekday_entry|get_obj_attr:form.attr %} 6 | 13 | 18 | {% endwith %} 19 | {% endfor %} 20 |
21 | -------------------------------------------------------------------------------- /web/service/pie_graph.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from django.contrib.auth.models import User 4 | from django.db.models import Count 5 | 6 | from web.service.base_graph import PERIODS, BaseGraph 7 | from web.structs import PieChartResponse 8 | 9 | 10 | class PieGraphService(BaseGraph): 11 | def __init__(self, user: User, mood_mapping: dict, start_dt: date, end_dt: date): 12 | super().__init__(start_dt=start_dt, end_dt=end_dt) 13 | self.user = user 14 | self.mood_mapping = mood_mapping 15 | 16 | def load_data(self, period: str) -> PieChartResponse: 17 | if period not in PERIODS: 18 | raise ValueError(f"period must be one of {PERIODS}") 19 | qs = ( 20 | self.date_range_qs() 21 | .values(period) 22 | .annotate(total=Count(period)) 23 | .order_by("total") 24 | ) 25 | labels = [] 26 | values = [] 27 | for item in qs: 28 | if not item[period]: 29 | continue 30 | labels.append(item[period]) 31 | values.append(item["total"]) 32 | return PieChartResponse(label_numbers=labels, values=values) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stimmungskalender 2 | 3 | `stimmungskalender` is a mood calendar. It is a simple but effective way of keeping track of your daily mood and sleep. 4 | 5 | This can can help you identify patterns to establish a better understanding of your wellbeing or sleeping habits. 6 | 7 | It's built with [Django](https://www.djangoproject.com/) and can be self-hosted. 8 | 9 | --- 10 | 11 | ## Features 12 | 13 | * You can track your mood of each day and night 14 | * Every week can be annotated with a note 15 | * Diagrams (build with [Plotly](https://plotly.com/)) 16 | * Localized (currently in English and German) 17 | * If you like to track only daytime (or only nights), you can choose so in the settings 18 | * REST-api (if you like to build your own frontend) 19 | * Privacy-friendly: no external resources (css, js) loaded 20 | 21 | --- 22 | 23 | ![Screenshot of Stimmungskalender](assets/form.png) 24 | 25 | --- 26 | 27 | The easiest way to get `stimmungskalender` up and running is with Docker. See: [`docker.md`](docs/docker.md) 28 | 29 | 30 | There is also a [`faq.md`](docs/faq.md). 31 | 32 | ## REST API 33 | 34 | An OpenAPI 3.0 schema can be accessed under `/api/schema/` and swagger under `/api/schema/swagger-ui/`. -------------------------------------------------------------------------------- /web/migrations/0002_auto_20210805_2201.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2021-08-05 20:01 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="entry", 14 | name="mood_day", 15 | field=models.IntegerField( 16 | choices=[ 17 | (1, "Very Bad"), 18 | (2, "Bad"), 19 | (3, "Medium"), 20 | (4, "Good"), 21 | (5, "Very Good"), 22 | ], 23 | null=True, 24 | ), 25 | ), 26 | migrations.AlterField( 27 | model_name="entry", 28 | name="mood_night", 29 | field=models.IntegerField( 30 | choices=[ 31 | (1, "Very Bad"), 32 | (2, "Bad"), 33 | (3, "Medium"), 34 | (4, "Good"), 35 | (5, "Very Good"), 36 | ], 37 | null=True, 38 | ), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /web/migrations/0008_usersettings.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-07 18:07 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("web", "0007_note_year"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="UserSettings", 17 | fields=[ 18 | ( 19 | "id", 20 | models.BigAutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("view_is_markers", models.BooleanField(default=True)), 28 | ( 29 | "user", 30 | models.ForeignKey( 31 | on_delete=django.db.models.deletion.CASCADE, 32 | to=settings.AUTH_USER_MODEL, 33 | ), 34 | ), 35 | ], 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /web/templates/web/settings/language.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |

{% translate 'language' %}

3 |
4 | {% csrf_token %} 5 | 6 |
7 |
8 | 18 |
19 |
20 | 21 |
22 |
23 |
24 |
25 | {% translate 'current_language' %}: 26 | {{ LANGUAGE_CODE }} 27 |
28 | -------------------------------------------------------------------------------- /node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stimmungskalender", 3 | "description": "JavaScript and CSS files for stimmungskalender", 4 | "main": "index.js", 5 | "scripts": { 6 | "build": "webpack --mode=production", 7 | "dev-build": "webpack --mode=development", 8 | "watch": "webpack --watch --mode=development", 9 | "format": "prettier --write . && package-lock-sanitizer" 10 | }, 11 | "keywords": [], 12 | "author": "Rainer Hihn (https://hihn.org/)", 13 | "license": "AGPL-3.0", 14 | "devDependencies": { 15 | "autoprefixer": "^10.4.20", 16 | "copy-webpack-plugin": "^12.0.2", 17 | "css-loader": "^7.1.2", 18 | "file-loader": "^6.2.0", 19 | "mini-css-extract-plugin": "^2.9.2", 20 | "package-lock-sanitizer": "^1.0.4", 21 | "postcss-loader": "^8.1.1", 22 | "prettier": "3.4.2", 23 | "sass": "^1.83.4", 24 | "sass-loader": "^16.0.4", 25 | "style-loader": "^4.0.0", 26 | "webpack": "^5.97.1", 27 | "webpack-cli": "^6.0.1", 28 | "webpack-dev-server": "^5.2.0" 29 | }, 30 | "dependencies": { 31 | "@popperjs/core": "^2.11.8", 32 | "bootstrap": "^5.3.3", 33 | "js-year-calendar": "^2.0.0", 34 | "npm-check-updates": "^17.1.14", 35 | "plotly.js": "^2.35.3", 36 | "vanillajs-datepicker": "^1.3.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /web/management/commands/generate_random_data.py: -------------------------------------------------------------------------------- 1 | import random 2 | from datetime import timedelta 3 | 4 | from django.conf import settings 5 | from django.core.management import BaseCommand, CommandParser 6 | from django.utils import timezone 7 | from django_registration.forms import User 8 | 9 | from web.models import PERIODS, Moods, Week 10 | from web.service.sk import SkService 11 | 12 | 13 | class Command(BaseCommand): 14 | def add_arguments(self, parser: CommandParser) -> None: 15 | parser.add_argument("username") 16 | parser.add_argument("days", type=int, default=30, nargs="?") 17 | 18 | def handle(self, *args: tuple, **options: dict) -> None: 19 | user = User.objects.get(username=options.get("username")) 20 | days = options.get("days", 30) 21 | Week.objects.filter(user=user).delete() 22 | sk_service = SkService(user) 23 | days = [(timezone.now() - timedelta(days=d)).date() for d in range(days)] 24 | mood_list = list(Moods) 25 | 26 | for period in PERIODS: 27 | for day in days: 28 | mood = random.choice(mood_list) 29 | sk_service.save_entry( 30 | period, 31 | mood, 32 | day.strftime(settings.SK_DATE_FORMAT), 33 | ) 34 | -------------------------------------------------------------------------------- /web/templates/web/settings/mood_colors.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |

{% translate 'mood_colors' %}

3 |
4 | {% translate 'you_can_set_a_color_for_each_color' %}. 5 | {% translate 'examples' %} 6 | : red, #dc3545, rgba(40,167,69, 0.7) 7 |
8 |
9 |
10 | {% for row in user_colors_settings %} 11 |
12 | 13 |
14 | 19 |
20 |
21 |
22 | {% endfor %} 23 |
24 |
25 | 29 |
30 |
31 | -------------------------------------------------------------------------------- /web/templates/web/shared/pagination.html: -------------------------------------------------------------------------------- 1 | {% load relative_url %} 2 | {% load i18n %} 3 | {% if page_obj.has_other_pages %} 4 | {% with params=request.GET.urlencode %} 5 |
6 | {% if page_obj.has_previous %} 7 | 12 | {% endif %} 13 |
14 | {% translate 'page' %} {{ page_obj.number }}. 15 |
16 | {% if page_obj.has_next %} 17 | 23 | {% endif %} 24 |
25 | {% endwith %} 26 | {% endif %} 27 | -------------------------------------------------------------------------------- /node/webpack.config.js: -------------------------------------------------------------------------------- 1 | const miniCssExtractPlugin = require("mini-css-extract-plugin"); 2 | const path = require("path"); 3 | const CopyPlugin = require("copy-webpack-plugin"); 4 | 5 | module.exports = { 6 | stats: "errors-only", 7 | mode: "production", 8 | entry: { main: "./src/js/main.js", plotly: "./src/js/plotly.js" }, 9 | output: { 10 | filename: "[name].js", 11 | path: path.resolve(__dirname, "../web/static"), 12 | }, 13 | plugins: [ 14 | new miniCssExtractPlugin(), 15 | new CopyPlugin({ 16 | patterns: [ 17 | { 18 | from: "src/scss/signin.css", 19 | to: path.resolve(__dirname, "../web/static"), 20 | }, 21 | ], 22 | }), 23 | ], 24 | module: { 25 | rules: [ 26 | { 27 | test: /\.css$/i, 28 | use: ["style-loader", "css-loader"], 29 | }, 30 | { 31 | test: /\.(scss)$/, 32 | use: [ 33 | { 34 | loader: miniCssExtractPlugin.loader, 35 | }, 36 | { 37 | loader: "css-loader", 38 | }, 39 | { 40 | loader: "postcss-loader", 41 | options: { 42 | postcssOptions: { 43 | plugins: () => [require("autoprefixer")], 44 | }, 45 | }, 46 | }, 47 | { 48 | loader: "sass-loader", 49 | }, 50 | ], 51 | }, 52 | ], 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /web/service/scatter_graph.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from datetime import date, timedelta 3 | 4 | from django.contrib.auth.models import User 5 | 6 | from web.service.base_graph import BaseGraph 7 | from web.structs import ScatterGraphDataPointY, ScatterGraphResponse 8 | 9 | 10 | class ScatterGraphService(BaseGraph): 11 | def __init__( 12 | self, 13 | is_markers: bool, 14 | mood_mapping: dict, 15 | user: User, 16 | start_dt: date, 17 | end_dt: date, 18 | ): 19 | super().__init__(start_dt=start_dt, end_dt=end_dt) 20 | self.is_markers = is_markers 21 | self.mood_mapping = mood_mapping 22 | self.user = user 23 | 24 | def load_data(self) -> typing.List[ScatterGraphResponse]: 25 | """ 26 | Loads data from the last seven days if no dates are provided 27 | """ 28 | data = {} 29 | day_count = self.build_day_range(self.start_dt, self.end_dt) 30 | days = [(self.start_dt + timedelta(days=d)) for d in range(day_count)] 31 | for day in days: 32 | data[day] = ScatterGraphDataPointY(day=0, night=0) 33 | entries = self.date_range_qs() 34 | for entry in entries.iterator(): 35 | data[entry.day] = ScatterGraphDataPointY( 36 | day=entry.mood_day, night=entry.mood_night 37 | ) 38 | ret = [] 39 | for day in days: 40 | ret.append(ScatterGraphResponse(x=day, y=data[day])) 41 | return ret 42 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | # Database 3 | db: 4 | image: "postgres:16-alpine" 5 | container_name: "sk-db" 6 | restart: unless-stopped 7 | environment: 8 | LC_ALL: C.UTF-8 9 | POSTGRES_USER: stimmungskalender 10 | POSTGRES_PASSWORD: stimmungskalender 11 | POSTGRES_DB: stimmungskalender 12 | volumes: 13 | - "db:/var/lib/postgresql/data" 14 | 15 | # Django app 16 | app: 17 | image: "rain0r/stimmungskalender-app" 18 | container_name: "sk-app" 19 | build: 20 | dockerfile: ./docker/app/Dockerfile 21 | platforms: 22 | - "linux/amd64" 23 | - "linux/arm64" 24 | args: 25 | DATABASE_URL: "pgsql://stimmungskalender:stimmungskalender@db/stimmungskalender" 26 | LOG_FILE_PATH: /logs/stimmungskalender.log 27 | STATIC_ROOT: /srv/www/stimmungskalender/static/ 28 | environment: 29 | ALLOWED_HOSTS: ${ALLOWED_HOSTS:-127.0.0.1-localhost} # Add your Stimmungskalender-URL here 30 | DATABASE_URL: "pgsql://stimmungskalender:stimmungskalender@db/stimmungskalender" 31 | FIRST_DAY_OF_WEEK: 1 32 | LANGUAGE_CODE: de-de 33 | LOG_FILE_PATH: /logs/stimmungskalender.log 34 | REGISTRATION_OPEN: False 35 | SECRET_KEY: "PLEASE-CHANGE-ME" 36 | STATIC_ROOT: /srv/www/stimmungskalender/static/ 37 | TIME_ZONE: Europe/Berlin 38 | ports: 39 | - "7890:8000" 40 | volumes: 41 | - "static:/srv/www/stimmungskalender/static" 42 | - "./logs:/logs" 43 | depends_on: 44 | - db 45 | 46 | volumes: 47 | db: 48 | static: 49 | -------------------------------------------------------------------------------- /web/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2021-08-04 09:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Entry", 14 | fields=[ 15 | ( 16 | "id", 17 | models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ( 25 | "mood_day", 26 | models.IntegerField( 27 | choices=[ 28 | (1, "Very Bad"), 29 | (2, "Bad"), 30 | (3, "Medium"), 31 | (4, "Good"), 32 | (5, "Very Good"), 33 | ] 34 | ), 35 | ), 36 | ( 37 | "mood_night", 38 | models.IntegerField( 39 | choices=[ 40 | (1, "Very Bad"), 41 | (2, "Bad"), 42 | (3, "Medium"), 43 | (4, "Good"), 44 | (5, "Very Good"), 45 | ] 46 | ), 47 | ), 48 | ("day", models.DateField()), 49 | ], 50 | ), 51 | ] 52 | -------------------------------------------------------------------------------- /node/src/js/main.js: -------------------------------------------------------------------------------- 1 | // Import all of Bootstrap's JS 2 | import * as bootstrap from "bootstrap"; 3 | 4 | import { SkCalendar } from "./sk.calendar"; 5 | import { MoodForm } from "./sk.mood.form"; 6 | import { Theme } from "./sk.theme"; 7 | import { Graph } from "./sk.graph"; 8 | import * as SkUtil from "./sk.util"; 9 | 10 | import "../scss/main.scss"; 11 | 12 | const loadTranslation = (baseUrl) => { 13 | const currentLanguage = JSON.parse( 14 | document.getElementById("current_language").textContent 15 | ); 16 | return fetch(`${baseUrl}jsoni18n/?lang=${currentLanguage}`) 17 | .then((response) => response.json()) 18 | .catch((err) => { 19 | console.error("Error", err); 20 | }); 21 | }; 22 | 23 | const ready = (callback) => { 24 | if (document.readyState != "loading") callback(); 25 | else document.addEventListener("DOMContentLoaded", callback); 26 | }; 27 | ready(() => { 28 | const apiUrlsElem = document.querySelector("#api_urls"); 29 | if (!apiUrlsElem) { 30 | return; 31 | } 32 | const apiUrls = JSON.parse(apiUrlsElem.textContent); 33 | const activeUrl = JSON.parse( 34 | document.querySelector("#active_url").textContent 35 | ); 36 | 37 | SkUtil.enableToolTip(); 38 | SkUtil.enableToast(); 39 | SkUtil.enableDatePicker(); 40 | new Theme(); 41 | 42 | loadTranslation(apiUrls["base-url"]).then((translation) => { 43 | switch (activeUrl) { 44 | case "calendar": 45 | new SkCalendar(apiUrls, translation["catalog"]); 46 | break; 47 | case "index": 48 | new MoodForm(apiUrls); 49 | break; 50 | case "graph": 51 | new Graph(apiUrls, translation["catalog"]); 52 | break; 53 | } 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /node/src/scss/main.scss: -------------------------------------------------------------------------------- 1 | @import "datePicker.scss"; 2 | @import "stimmungskalender.scss"; 3 | @import "bootstrap.scss"; 4 | 5 | #read-only-note { 6 | white-space: pre-wrap; 7 | } 8 | 9 | .standout-data-good { 10 | background-color: $green-300; 11 | color: $black; 12 | } 13 | 14 | .standout-data-bad { 15 | background-color: $red-400; 16 | color: $black; 17 | } 18 | 19 | .btn-mood-1 { 20 | background-color: $red-500 !important; 21 | } 22 | 23 | .btn-mood-2 { 24 | background-color: $yellow-300 !important; 25 | } 26 | 27 | .btn-mood-3 { 28 | background-color: $green-100 !important; 29 | } 30 | 31 | .btn-mood-4 { 32 | background-color: $green-400 !important; 33 | } 34 | 35 | .btn-mood-5 { 36 | background-color: $green-500 !important; 37 | } 38 | 39 | .btn-check:checked + .btn, 40 | :not(.btn-check) + .btn:active, 41 | .btn:first-child:active, 42 | .btn.active, 43 | .btn.show { 44 | color: $black; 45 | } 46 | 47 | [data-bs-theme="dark"] { 48 | .bg-tertiary { 49 | background-color: var(--bs-tertiary-bg) !important; 50 | } 51 | 52 | .form-night { 53 | @include gradient-y(var(--bs-tertiary-bg), var(--bs-secondary-bg)); 54 | } 55 | 56 | .form-day { 57 | color: $black; 58 | @include gradient-y($gray-400, $yellow-100); 59 | } 60 | 61 | .form-text-color-day { 62 | color: $black; 63 | } 64 | } 65 | 66 | [data-bs-theme="light"] { 67 | body { 68 | color: $black; 69 | background-color: $gray-100 !important; 70 | } 71 | 72 | .form-night { 73 | @include gradient-y($white, $blue-300); 74 | color: $black; 75 | } 76 | 77 | .form-day { 78 | color: $black; 79 | @include gradient-y($gray-100, $yellow-100); 80 | } 81 | 82 | .form-text-color-day { 83 | color: $black; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /web/structs.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from dataclasses import dataclass 3 | from datetime import date 4 | 5 | from web.models import Entry, Week 6 | 7 | 8 | @dataclass 9 | class WeekdayEntry: 10 | day: date 11 | mood_day: typing.Optional[int] = 0 12 | mood_night: typing.Optional[int] = 0 13 | 14 | 15 | @dataclass 16 | class MoodTable: 17 | days_of_week: typing.List[WeekdayEntry] 18 | week: Week 19 | next_week: str 20 | prev_week: str 21 | 22 | 23 | @dataclass 24 | class StandoutData: 25 | label: str 26 | css_class: str 27 | entry: Entry 28 | 29 | 30 | @dataclass 31 | class GraphTimeRanges: 32 | first_day: str 33 | last_week_start_dt: str 34 | last_month_start_dt: str 35 | last_year_start_dt: str 36 | 37 | 38 | @dataclass 39 | class SkCalendar: 40 | first_day: date 41 | last_day: date 42 | entries: typing.List[WeekdayEntry] 43 | 44 | 45 | @dataclass 46 | class ScatterGraphDataPointY: 47 | """ 48 | Data class for the y-axis of the scatter graph. 49 | """ 50 | 51 | day: int 52 | night: int 53 | 54 | 55 | @dataclass 56 | class ScatterGraphResponse: 57 | """ 58 | Data class for the scatter graph. 59 | """ 60 | 61 | x: str 62 | y: ScatterGraphDataPointY 63 | 64 | 65 | @dataclass 66 | class PieChartResponse: 67 | """ 68 | Data class for the pie chart graph. 69 | """ 70 | 71 | label_numbers: typing.List[int] 72 | values: typing.List[int] 73 | 74 | 75 | @dataclass 76 | class BarChartResponse: 77 | """ 78 | Data class for the "Average Mood" bar chart. 79 | """ 80 | 81 | labels: typing.List[str] 82 | values: typing.List[float] 83 | 84 | 85 | @dataclass 86 | class GeneralStats: 87 | day_count: int 88 | night_count: int 89 | 90 | 91 | @dataclass 92 | class ExportData: 93 | entries: SkCalendar 94 | moods: dict 95 | weeks: typing.List[Week] 96 | -------------------------------------------------------------------------------- /web/templates/web/graph/graph.html: -------------------------------------------------------------------------------- 1 | {% extends 'web/base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | {% block title %} 5 | {% translate 'graphs' %} 6 | {% endblock %} 7 | {% block content %} 8 | {% load relative_url %} 9 |
13 | 14 | 17 |
{% include 'web/graph/date_select.html' %}
18 | 33 |
34 |
{% include 'web/graph/scatter.html' %}
35 |
{% include 'web/graph/pie_chart.html' %}
36 |
{% include 'web/graph/average.html' %}
37 |
38 |
39 |
40 | {% endblock %} 41 | {% block js %} 42 | 43 | {% endblock js %} 44 | -------------------------------------------------------------------------------- /node/src/js/sk.theme.js: -------------------------------------------------------------------------------- 1 | export class Theme { 2 | constructor() { 3 | this.storedTheme = localStorage.getItem("theme"); 4 | this.setTheme(this.getPreferredTheme()); 5 | this.setupPrefChangeListener(); 6 | 7 | this.showActiveTheme(this.getPreferredTheme()); 8 | const themeToggleCheckbox = document.querySelector("#dark-theme"); 9 | 10 | themeToggleCheckbox.addEventListener("change", () => { 11 | const theme = themeToggleCheckbox.checked === true ? "dark" : "light"; 12 | localStorage.setItem("theme", theme); 13 | this.setTheme(theme); 14 | this.showActiveTheme(theme); 15 | }); 16 | } 17 | 18 | setupPrefChangeListener() { 19 | window 20 | .matchMedia("(prefers-color-scheme: dark)") 21 | .addEventListener("change", () => { 22 | if (this.storedTheme !== "light" || this.storedTheme !== "dark") { 23 | this.setTheme(this.getPreferredTheme()); 24 | } 25 | }); 26 | } 27 | 28 | getPreferredTheme() { 29 | if (this.storedTheme) { 30 | return this.storedTheme; 31 | } 32 | 33 | return window.matchMedia("(prefers-color-scheme: dark)").matches 34 | ? "dark" 35 | : "light"; 36 | } 37 | 38 | setTheme(theme) { 39 | if ( 40 | theme === "auto" && 41 | window.matchMedia("(prefers-color-scheme: dark)").matches 42 | ) { 43 | document.documentElement.setAttribute("data-bs-theme", "dark"); 44 | } else { 45 | document.documentElement.setAttribute("data-bs-theme", theme); 46 | } 47 | } 48 | 49 | showActiveTheme(theme) { 50 | const checkbox = document.querySelector("#dark-theme"); 51 | if (checkbox) { 52 | checkbox.checked = theme === "dark"; 53 | } 54 | } 55 | } 56 | 57 | /*! 58 | * Color mode toggler for Bootstrap's docs (https://getbootstrap.com/) 59 | * Copyright 2011-2022 The Bootstrap Authors 60 | * Licensed under the Creative Commons Attribution 3.0 Unported License. 61 | */ 62 | 63 | (() => {})(); 64 | -------------------------------------------------------------------------------- /docs/docker.md: -------------------------------------------------------------------------------- 1 | # Docker 2 | 3 | Docker Compose is the recommended method to run `stimmungskalender` in production. 4 | Below are the steps to deploy `stimmungskalender` with Docker Compose. 5 | 6 | ## Step 1 - Download the required files 7 | 8 | Create a directory of your choice (e.g. `./stimmungskalender`) to hold the docker-compose.yml file. 9 | 10 | ```sh 11 | mkdir ./stimmungskalender && cd ./stimmungskalender 12 | ``` 13 | 14 | Download `docker-compose.yml` by running the following commands: 15 | 16 | ```sh 17 | wget -O docker-compose.yml https://raw.githubusercontent.com/rain0r/stimmungskalender/refs/heads/master/docker-compose.yml 18 | ``` 19 | 20 | ## Step 2 - Edit `docker-compose.yml` 21 | 22 | Open the file `docker-compose.yml` with your favorite editor and adjust some settings: 23 | 24 | Under `app > environment`, there are some settings you may want to edit: 25 | 26 | - `ALLOWED_HOSTS`: add the external URL of your `stimmungskalender` installation 27 | - `SECRET_KEY`: should be set to a unique, unpredictable value [Django Docs](https://docs.djangoproject.com/en/5.0/ref/settings/#secret-key) 28 | - `TIME_ZONE` : the time zone for this installation (default: `Europe/Berlin`) 29 | - `FIRST_DAY_OF_WEEK`: `0` sunday, `1`: monday (default) 30 | - `LANGUAGE_CODE`: [Django Docs](https://docs.djangoproject.com/en/5.0/ref/settings/#language-code) (default: `de-de`) 31 | 32 | Now, initialise the database and create the first user. 33 | You will be prompted for a username, a password and an email address. 34 | You can leave the email address empty. 35 | 36 | ```sh 37 | docker compose run app first_run 38 | ``` 39 | 40 | ## Step 3 - Start the containers 41 | 42 | From the directory you created in Step 1 (which should now contain your customized `docker-compose.yml`), 43 | run the following command to start `stimmungskalender` as a background service: 44 | 45 | ```sh 46 | docker compose up -d 47 | ``` 48 | 49 | Visit http://localhost:7890 and log in with the user you just created. -------------------------------------------------------------------------------- /web/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.core.handlers.wsgi import WSGIRequest 2 | from django.urls import resolve, reverse_lazy 3 | from django.utils.translation import get_language 4 | from django.utils.translation import gettext as _ 5 | from django.utils.translation import to_locale 6 | 7 | from web.service.settings import SettingsService 8 | 9 | 10 | def lang(request: WSGIRequest) -> dict: 11 | return { 12 | "sk_language_code": get_language().split("-")[0], 13 | "sk_language": get_language(), 14 | "sk_locale": to_locale(get_language()), 15 | } 16 | 17 | 18 | def mood_colors(request: WSGIRequest) -> dict: 19 | ret = {} 20 | if request.user.is_authenticated: 21 | ss = SettingsService(request.user) 22 | for obj in ss.user_colors_settings(): 23 | ret[obj.mood] = obj.color 24 | return {"mood_colors": ret} 25 | 26 | 27 | def mood_names(request: WSGIRequest) -> dict: 28 | return { 29 | "mood_names": { 30 | 1: _("very_bad"), 31 | 2: _("bad"), 32 | 3: _("medium"), 33 | 4: _("good"), 34 | 5: _("very_good"), 35 | } 36 | } 37 | 38 | 39 | def api_urls(request: WSGIRequest) -> dict: 40 | """ 41 | Provide urls to api endpoints. 42 | :param request: 43 | :return: 44 | """ 45 | return { 46 | "api_urls": { 47 | "base-url": reverse_lazy("index"), 48 | "api-entry-day": reverse_lazy("api-entry-day"), 49 | "api-mood-table": reverse_lazy("api-mood-table"), 50 | "api-calendar": reverse_lazy("api-calendar"), 51 | "api-scatter-plot": reverse_lazy("api-scatter-plot"), 52 | "api-pie-chart": reverse_lazy("api-pie-chart"), 53 | "api-mood-colors": reverse_lazy("api-mood-colors"), 54 | "api-bar-chart": reverse_lazy("api-bar-chart"), 55 | } 56 | } 57 | 58 | 59 | def active_url(request: WSGIRequest) -> dict: 60 | current_url = resolve(request.path_info).url_name 61 | return {"active_url": current_url} 62 | -------------------------------------------------------------------------------- /docker/app/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | export DJANGO_SUPERUSER_USERNAME=admin 6 | export DJANGO_SUPERUSER_PASSWORD=admin 7 | export DJANGO_SUPERUSER_EMAIL=admin@example.com 8 | 9 | chown -R www-data:www-data /logs 10 | 11 | BASE_DIR="/srv/www/stimmungskalender" 12 | V_ENV="${BASE_DIR}/.venv" 13 | PYTHON="${V_ENV}/bin/python" 14 | 15 | # Define help message 16 | show_help() { 17 | echo """ 18 | Usage: docker run COMMAND 19 | 20 | Commands 21 | 22 | sh : Start /bin/sh 23 | default_user: default_user 24 | dev : Start a normal Django development server 25 | first_run : Setup the initial database 26 | help : Show this message 27 | manage : Start manage.py 28 | shell : Start a Django Python shell 29 | translate : Create translation messages 30 | uwsgi : Run uwsgi server 31 | """ 32 | } 33 | 34 | translate() { 35 | cd web 36 | find . -type f -name "*.mo" -delete 37 | $PYTHON "${V_ENV}/bin/django-admin" makemessages -l de_DE # > /dev/null 2>&1 38 | $PYTHON "${V_ENV}/bin/django-admin" makemessages -l en_GB # > /dev/null 2>&1 39 | $PYTHON "${V_ENV}/bin/django-admin" compilemessages # > /dev/null 2>&1 40 | cd .. 41 | } 42 | 43 | 44 | # Run 45 | case "$1" in 46 | sh) 47 | /bin/sh "${@:2}" 48 | ;; 49 | default_user) 50 | $PYTHON ${BASE_DIR}/manage.py createsuperuser --noinput --username $DJANGO_SUPERUSER_USERNAME --email $DJANGO_SUPERUSER_EMAIL 51 | ;; 52 | dev) 53 | echo "Running Development Server on 0.0.0.0:${PORT}" 54 | $PYTHON ${BASE_DIR}/manage.py runserver 0.0.0.0:${PORT} 55 | ;; 56 | first_run) 57 | $PYTHON ${BASE_DIR}/manage.py migrate 58 | translate 59 | $PYTHON ${BASE_DIR}/manage.py collectstatic --noinput 60 | $PYTHON ${BASE_DIR}/manage.py createsuperuser 61 | ;; 62 | manage) 63 | $PYTHON ${BASE_DIR}/manage.py "${@:2}" 64 | ;; 65 | shell) 66 | $PYTHON ${BASE_DIR}/manage.py shell 67 | ;; 68 | translate) 69 | translate 70 | ;; 71 | uwsgi) 72 | echo "Running App (uWSGI)..." 73 | uwsgi --ini /srv/www/stimmungskalender/docker/app/uwsgi.ini 74 | ;; 75 | *) 76 | show_help 77 | ;; 78 | esac 79 | -------------------------------------------------------------------------------- /web/templates/web/settings/settings.html: -------------------------------------------------------------------------------- 1 | {% extends 'web/base.html' %} 2 | {% load i18n %} 3 | {% block title %} 4 | {% translate 'settings' %} 5 | {% endblock %} 6 | {% block content %} 7 | {% load i18n %} 8 | {% get_current_language as LANGUAGE_CODE %} 9 | 12 | {% include 'web/settings/language.html' %} 13 |
14 |
15 | {% csrf_token %} 16 | {% include 'web/settings/default_view_mode.html' %} 17 |
18 | {% include 'web/settings/view_forms.html' %} 19 |
20 | {% include 'web/settings/js_btn.html' %} 21 |
22 | {% if not user_settings.use_js_btn %} 23 | {% include 'web/settings/mood_colors.html' %} 24 |
25 | {% endif %} 26 | 27 |
28 |
29 |

{% translate 'user_mgmt' %}

30 | {% translate 'add_edit_remove_user' %} Admin Site. 31 | 32 |
33 |

API

34 |
35 | 43 |
44 |
45 |
46 | 55 |
56 | {% endblock %} 57 | -------------------------------------------------------------------------------- /web/migrations/0023_usermoodcolorsettings_remove_week_unique entry_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-06-22 16:23 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("web", "0022_usersettings_view_day_form_and_more"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="UserMoodColorSettings", 17 | fields=[ 18 | ( 19 | "id", 20 | models.BigAutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ( 28 | "mood", 29 | models.IntegerField( 30 | choices=[ 31 | (1, "Very Bad"), 32 | (2, "Bad"), 33 | (3, "Medium"), 34 | (4, "Good"), 35 | (5, "Very Good"), 36 | ] 37 | ), 38 | ), 39 | ("color", models.CharField(max_length=32)), 40 | ], 41 | ), 42 | migrations.RemoveConstraint( 43 | model_name="week", 44 | name="unique entry", 45 | ), 46 | migrations.AddConstraint( 47 | model_name="entry", 48 | constraint=models.UniqueConstraint( 49 | fields=("user", "day"), name="Unique user and day" 50 | ), 51 | ), 52 | migrations.AddConstraint( 53 | model_name="week", 54 | constraint=models.UniqueConstraint( 55 | fields=("user", "week_date"), name="Unique user and week_date" 56 | ), 57 | ), 58 | migrations.AddField( 59 | model_name="usermoodcolorsettings", 60 | name="user", 61 | field=models.ForeignKey( 62 | on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL 63 | ), 64 | ), 65 | ] 66 | -------------------------------------------------------------------------------- /web/templates/web/graph/date_select.html: -------------------------------------------------------------------------------- 1 | {% load relative_url %} 2 | {% load i18n %} 3 | 4 |
5 |
6 |
7 | 8 | 15 |
16 |
17 | 18 | 25 |
26 |
27 | 28 |
29 |
30 |
31 | 32 | 52 | -------------------------------------------------------------------------------- /web/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework_dataclasses.serializers import DataclassSerializer 3 | 4 | from web import models 5 | from web.structs import ( 6 | BarChartResponse, 7 | ExportData, 8 | GraphTimeRanges, 9 | MoodTable, 10 | PieChartResponse, 11 | ScatterGraphDataPointY, 12 | ScatterGraphResponse, 13 | SkCalendar, 14 | StandoutData, 15 | WeekdayEntry, 16 | ) 17 | 18 | 19 | class WeekSerializer(serializers.ModelSerializer): 20 | class Meta: 21 | model = models.Week 22 | fields = ["week_date", "note"] 23 | 24 | 25 | class UserSettingsSerializer(serializers.ModelSerializer): 26 | class Meta: 27 | model = models.UserSettings 28 | fields = ["view_is_markers", "view_day_form", "view_night_form"] 29 | 30 | 31 | class UserMoodColorSettingsSerializer(serializers.ModelSerializer): 32 | class Meta: 33 | model = models.UserMoodColorSettings 34 | fields = ["mood", "color"] 35 | 36 | 37 | class WeekdayEntrySerializer(DataclassSerializer): 38 | class Meta: 39 | dataclass = WeekdayEntry 40 | 41 | 42 | class GraphTimeRangesSerializer(DataclassSerializer): 43 | class Meta: 44 | dataclass = GraphTimeRanges 45 | 46 | 47 | class CalendarSerializer(DataclassSerializer): 48 | class Meta: 49 | dataclass = SkCalendar 50 | 51 | 52 | class GraphDataPointYSerializer(DataclassSerializer): 53 | class Meta: 54 | dataclass = ScatterGraphDataPointY 55 | 56 | 57 | class ScatterGraphResponseSerializer(DataclassSerializer): 58 | class Meta: 59 | dataclass = ScatterGraphResponse 60 | 61 | 62 | class PieChartResponseSerializer(DataclassSerializer): 63 | class Meta: 64 | dataclass = PieChartResponse 65 | 66 | 67 | class BarChartResponseSerializer(DataclassSerializer): 68 | class Meta: 69 | dataclass = BarChartResponse 70 | 71 | 72 | class MoodTableSerializer(DataclassSerializer): 73 | week = WeekSerializer() 74 | 75 | class Meta: 76 | dataclass = MoodTable 77 | 78 | 79 | class StandoutDataSerializer(DataclassSerializer): 80 | entry = WeekdayEntrySerializer() 81 | 82 | class Meta: 83 | dataclass = StandoutData 84 | 85 | 86 | class ExportDataSerializer(DataclassSerializer): 87 | weeks = serializers.ListField(child=WeekSerializer()) 88 | 89 | class Meta: 90 | dataclass = ExportData 91 | -------------------------------------------------------------------------------- /web/service/settings.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from django.contrib.auth.models import User 4 | from django.http import QueryDict 5 | 6 | from web.models import Moods, UserMoodColorSettings, UserSettings 7 | from web.mood_colors import DEFAULT_COLORS 8 | 9 | 10 | class SettingsService: 11 | def __init__( 12 | self, 13 | user: User, 14 | ): 15 | self._user = user 16 | self._obj = UserSettings.objects.get(user=self._user) 17 | 18 | def save_user_colors_settings(self, colors=None) -> None: 19 | """ 20 | Sets a color to each mood. 21 | """ 22 | if colors is None: 23 | colors = dict() 24 | for mood in Moods: 25 | if f"mood-{mood}" in colors: 26 | color = colors.get(f"mood-{mood}") 27 | else: 28 | color = DEFAULT_COLORS.get(mood) 29 | UserMoodColorSettings.objects.update_or_create( 30 | user=self._user, 31 | mood=mood, 32 | defaults={"color": color}, 33 | ) 34 | 35 | def user_colors_settings(self) -> typing.List[UserMoodColorSettings]: 36 | """ 37 | Returns the color for each mood. 38 | :return: 39 | """ 40 | colors = [] 41 | for mood in Moods: 42 | obj, created = UserMoodColorSettings.objects.get_or_create( 43 | user=self._user, 44 | mood=mood, 45 | defaults={"color": DEFAULT_COLORS.get(mood)}, 46 | ) 47 | colors.append(obj) 48 | return colors 49 | 50 | def user_settings(self) -> UserSettings: 51 | return self._obj 52 | 53 | def set_forms_displayed(self, **kwargs: dict) -> None: 54 | self._obj.view_day_form = bool(kwargs.get("day")) 55 | self._obj.view_night_form = bool(kwargs.get("night")) 56 | self._obj.save() 57 | 58 | def get_default_view_mode(self) -> str: 59 | return "markers" if self._obj.view_is_markers else "lines" 60 | 61 | def is_markers(self, query_dict: QueryDict) -> bool: 62 | if "view" in query_dict: 63 | return query_dict.get("view", "markers") == "markers" 64 | else: 65 | return self.get_default_view_mode() == "markers" 66 | 67 | def set_markers(self, view_is_markers: str) -> None: 68 | self._obj.view_is_markers = view_is_markers == "markers" 69 | self._obj.save() 70 | 71 | def is_use_js_btn(self): 72 | return self._obj.use_js_btn 73 | 74 | def set_use_js_btn(self, enabled): 75 | self._obj.use_js_btn = enabled 76 | self._obj.save() 77 | -------------------------------------------------------------------------------- /web/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db import models 3 | from django.db.models.base import ModelBase 4 | from django.db.models.signals import post_save 5 | from django.dispatch import receiver 6 | 7 | PERIODS = ["day", "night"] 8 | 9 | 10 | class Moods(models.IntegerChoices): 11 | VERY_BAD = 1 12 | BAD = 2 13 | MEDIUM = 3 14 | GOOD = 4 15 | VERY_GOOD = 5 16 | 17 | 18 | class Entry(models.Model): 19 | user = models.ForeignKey(User, on_delete=models.CASCADE) 20 | week = models.ForeignKey("Week", on_delete=models.CASCADE, null=True) 21 | mood_day = models.IntegerField(choices=Moods.choices, null=True) 22 | mood_night = models.IntegerField(choices=Moods.choices, null=True) 23 | day = models.DateField(db_index=True) 24 | 25 | def __str__(self) -> str: 26 | return str( 27 | f"User: {self.user}, Date: {self.day}, Day: {self.mood_day}, Night: {self.mood_night}" 28 | ) 29 | 30 | class Meta: 31 | constraints = [ 32 | models.UniqueConstraint(fields=["user", "day"], name="Unique user and day") 33 | ] 34 | 35 | 36 | class Week(models.Model): 37 | user = models.ForeignKey(User, on_delete=models.CASCADE) 38 | week_date = models.DateField(db_index=True, null=True) # Start of the week 39 | note = models.TextField(blank=True) 40 | 41 | def __str__(self) -> str: 42 | return f"User: {self.user}, Week Date: {self.week_date}, Note: {self.note[:10]}" 43 | 44 | class Meta: 45 | constraints = [ 46 | models.UniqueConstraint( 47 | fields=["user", "week_date"], name="Unique user and week_date" 48 | ) 49 | ] 50 | 51 | 52 | class UserSettings(models.Model): 53 | """ 54 | SK settings for a user. 55 | """ 56 | 57 | user = models.OneToOneField(User, on_delete=models.CASCADE) 58 | view_is_markers = models.BooleanField(default=True) 59 | view_day_form = models.BooleanField(default=True) 60 | view_night_form = models.BooleanField(default=True) 61 | # Defines, if the mood form contains the default buttons or the new toggle button group. 62 | use_js_btn = models.BooleanField(default=True) 63 | 64 | 65 | class UserMoodColorSettings(models.Model): 66 | """ 67 | Maps user-defined colors to moods. 68 | """ 69 | 70 | user = models.ForeignKey(User, on_delete=models.CASCADE) 71 | mood = models.IntegerField(choices=Moods.choices) 72 | color = models.CharField(max_length=32) 73 | 74 | 75 | # Django database signals 76 | 77 | 78 | # Create a UserSettings entry for each user 79 | @receiver(post_save, sender=User) 80 | def create_user_settings( 81 | sender: ModelBase, instance: User, created: bool, **kwargs: dict 82 | ) -> None: 83 | if created: 84 | UserSettings.objects.create(user=instance) 85 | -------------------------------------------------------------------------------- /docker/app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/python:3.11-slim-bookworm AS python 2 | 3 | FROM python AS python-build-stage 4 | 5 | # Vars from the compose file 6 | ARG DATABASE_URL 7 | ARG LOG_FILE_PATH 8 | ARG STATIC_ROOT 9 | 10 | ENV PYTHONFAULTHANDLER=1 \ 11 | PYTHONUNBUFFERED=1 \ 12 | PYTHONHASHSEED=random \ 13 | PYTHONDONTWRITEBYTECODE=1 \ 14 | # pip: 15 | PIP_NO_CACHE_DIR=1 \ 16 | PIP_DISABLE_PIP_VERSION_CHECK=1 \ 17 | PIP_DEFAULT_TIMEOUT=100 \ 18 | # poetry: 19 | POETRY_CONFIG_DIR=/tmp/POETRY_CONFIG_DIR \ 20 | POETRY_DATA_DIR=/tmp/POETRY_DATA_DIR \ 21 | POETRY_CACHE_DIR=/tmp/POETRY_CACHE_DIR \ 22 | # django 23 | DATABASE_URL=$DATABASE_URL \ 24 | LOG_FILE_PATH=$LOG_FILE_PATH \ 25 | STATIC_ROOT=$STATIC_ROOT 26 | 27 | # Install apt packages 28 | RUN apt-get update && apt-get install --no-install-recommends -y \ 29 | # dependencies for building Python packages 30 | build-essential \ 31 | # psycopg dependencies 32 | libpq-dev 33 | 34 | WORKDIR /srv/www/stimmungskalender 35 | 36 | # Add the project source 37 | COPY --chown=www-data:www-data . . 38 | 39 | RUN python -m venv --clear .venv 40 | RUN ./.venv/bin/pip -qqq install -r requirements.txt 41 | RUN ./.venv/bin/pip -qqq install setuptools 42 | 43 | # Build frontend 44 | FROM docker.io/node:alpine3.20 AS client-builder 45 | 46 | COPY --from=python-build-stage /srv/www/stimmungskalender /srv/www/stimmungskalender 47 | 48 | WORKDIR /srv/www/stimmungskalender 49 | RUN ./scripts/node.sh 50 | RUN rm -rf ./node 51 | 52 | # Python 'run' stage 53 | FROM python AS python-run-stage 54 | 55 | ARG DATABASE_URL 56 | ARG LOG_FILE_PATH 57 | ARG STATIC_ROOT 58 | 59 | ENV PYTHONFAULTHANDLER=1 \ 60 | PYTHONUNBUFFERED=1 \ 61 | PYTHONHASHSEED=random \ 62 | PYTHONDONTWRITEBYTECODE=1 \ 63 | # django 64 | DATABASE_URL=$DATABASE_URL \ 65 | LOG_FILE_PATH=$LOG_FILE_PATH \ 66 | STATIC_ROOT=$STATIC_ROOT 67 | 68 | # Install required system dependencies 69 | RUN apt-get update && apt-get install --no-install-recommends -y \ 70 | # psycopg dependencies 71 | libpq-dev \ 72 | # Translations dependencies 73 | gettext \ 74 | # web server 75 | uwsgi-plugin-python3 \ 76 | # cleaning up unused files 77 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ 78 | && rm -rf /var/lib/apt/lists/* 79 | 80 | 81 | COPY --from=client-builder /srv/www/stimmungskalender /srv/www/stimmungskalender 82 | 83 | RUN mkdir /logs 84 | RUN touch /logs/stimmungskalender.log 85 | RUN chown -R www-data:www-data /logs 86 | 87 | WORKDIR /srv/www/stimmungskalender 88 | 89 | RUN ./.venv/bin/python manage.py collectstatic --noinput 90 | 91 | WORKDIR /srv/www/stimmungskalender/web 92 | RUN /srv/www/stimmungskalender/.venv/bin/django-admin compilemessages 93 | 94 | COPY --chown=www-data:www-data docker/app/entrypoint.sh /usr/local/bin/entrypoint.sh 95 | RUN chmod a+x /usr/local/bin/entrypoint.sh 96 | 97 | WORKDIR /srv/www/stimmungskalender/ 98 | ENTRYPOINT ["entrypoint.sh"] 99 | 100 | # Run uWSGI by default 101 | CMD ["uwsgi"] 102 | -------------------------------------------------------------------------------- /web/templates/web/mood-form/form.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% with "view_"|add:form.name|add:"_form" as period %} 3 | {% if request.user.usersettings|get_obj_attr:period %} 4 |
8 | {% csrf_token %} 9 | 10 |
13 | 16 | {% translate form.name_header %} 17 | {% if form.name == 'night' %} 18 | 🌙 19 | {% else %} 20 | ☀️ 21 | {% endif %} 22 | 23 |
24 |
25 |
26 |
27 | {% for weekday_entry in mood_table.days_of_week %} 28 | {{ weekday_entry.day|date:"D" }} 29 | {% endfor %} 30 |
31 |
32 | {% for weekday_entry in mood_table.days_of_week %} 33 |
35 |
36 | {{ weekday_entry.day|date:"D" }} 38 | {{ weekday_entry.day|date:"d. m." }} 39 |
40 | {% if js_btn %} 41 | {% include 'web/mood-form/js_btn.html' %} 42 | {% else %} 43 | {% include 'web/mood-form/default_btn.html' %} 44 | {% endif %} 45 |
46 | {% endfor %} 47 |
48 |
49 |
50 |
51 | {% endif %} 52 | {% endwith %} 53 | -------------------------------------------------------------------------------- /web/templates/web/mood-form/entry_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'web/base.html' %} 2 | {% load i18n %} 3 | {% block title %} 4 | {% translate 'home' %} 5 | {% endblock %} 6 | {% block content %} 7 | {% load i18n %} 8 | 11 |
12 |
13 |
14 | {% translate 'week' %} #{{ mood_table.week.week_date|date:"W" }} 16 | 17 | {{ mood_table.week.week_date }} 18 | — 19 | {{ mood_table.week.week_date|week_end_date }} 20 | 21 |
22 |
23 |
24 | 28 |
29 | {% translate 'prev' %} 31 | {% translate 'next' %} 33 |
34 |
35 | {% include 'web/mood-form/note.html' %} 36 |
37 | {% for form in forms %} 38 | {% include 'web/mood-form/form.html' with form=form %} 39 | {% endfor %} 40 | {% include 'web/mood-form/entry_posted_toast.html' %} 41 |
42 | {% translate 'jump_to_week' %} 43 |
44 |
45 | 50 |
51 |
52 | 53 |
54 |
55 |
56 | {% include 'web/mood-form/standout_data.html' %} 57 |
58 |
59 |
{{ general_stats.day_count }} {% translate 'days_tracked' %}.
60 |
{{ general_stats.night_count }} {% translate 'nights_tracked' %}.
61 |
62 |
63 | {% endblock %} 64 | -------------------------------------------------------------------------------- /stimmungskalender/urls.py: -------------------------------------------------------------------------------- 1 | """stimmungskalender URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | 17 | from django.conf import settings 18 | from django.conf.urls import include 19 | from django.contrib import admin 20 | from django.urls import path, re_path 21 | from rest_framework.schemas import get_schema_view 22 | 23 | from web import api, views 24 | 25 | handler400 = "web.views.custom_bad_request_view" 26 | handler403 = "web.views.custom_permission_denied_view" 27 | handler404 = "web.views.custom_page_not_found_view" 28 | handler500 = "web.views.custom_error_view" 29 | 30 | api_urlpatterns = [ 31 | path("api/entry-day/", api.EntryDayView.as_view(), name="api-entry-day"), 32 | path("api/mood-table/", api.MoodTableView.as_view(), name="api-mood-table"), 33 | path("api/standout-data/", api.StandoutDataView.as_view()), 34 | path("api/scatter-graph/", api.ScatterGraphView.as_view(), name="api-scatter-plot"), 35 | path("api/pie-chart-graph/", api.PieChartGraphView.as_view(), name="api-pie-chart"), 36 | path("api/bar-chart-graph/", api.BarChartGraphView.as_view(), name="api-bar-chart"), 37 | path("api/save-note/", api.SaveNoteView.as_view()), 38 | path("api/search/", api.SearchView.as_view()), 39 | path("api/graph/", api.GraphView.as_view()), 40 | path("api/calendar/", api.CalendarView.as_view(), name="api-calendar"), 41 | path("api/export/", api.ExportView.as_view(), name="export"), 42 | path("api/set-language/", api.SetLanguageView.as_view()), 43 | path("api/forms-displayed/", api.FormsDisplayedView.as_view()), 44 | path( 45 | "api/mood-colors/", 46 | api.UserMoodColorSettingsView.as_view(), 47 | name="api-mood-colors", 48 | ), 49 | ] 50 | 51 | urlpatterns = [ 52 | path("admin/", admin.site.urls), 53 | path("", include("django.contrib.auth.urls")), 54 | path("accounts/", include("django_registration.backends.one_step.urls")), 55 | path("accounts/", include("django.contrib.auth.urls")), 56 | path("", views.EntryListView.as_view(), name="index"), 57 | path("logout", views.LogoutView.as_view(), name="logout"), 58 | path("save-mood/", views.SaveMoodView.as_view(), name="save-mood"), 59 | path("save-note/", views.SaveNoteView.as_view(), name="save-note"), 60 | path("save-settings/", views.SaveSettingsView.as_view(), name="save-settings"), 61 | path("graph/", views.GraphView.as_view(), name="graph"), 62 | path("settings/", views.SettingsView.as_view(), name="settings"), 63 | path("search/", views.SearchView.as_view(), name="search"), 64 | path("calendar/", views.CalendarView.as_view(), name="calendar"), 65 | re_path(r"^rosetta/", include("rosetta.urls")), 66 | path("i18n/", include("django.conf.urls.i18n")), 67 | path( 68 | "jsoni18n/", 69 | api.SkJSONCatalog.as_view(domain="django"), 70 | name="json-catalog", 71 | ), 72 | path( 73 | "openapi", 74 | get_schema_view( 75 | title="Stimmungskalender", 76 | description="API for all things …", 77 | patterns=api_urlpatterns, 78 | ), 79 | name="openapi-schema", 80 | ), 81 | ] 82 | urlpatterns += api_urlpatterns 83 | 84 | # Host the static from uWSGI 85 | if settings.IS_WSGI: 86 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 87 | 88 | urlpatterns += staticfiles_urlpatterns() 89 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | asgiref==3.8.1 ; python_version >= "3.10" and python_version < "4.0" 2 | asttokens==3.0.0 ; python_version >= "3.10" and python_version < "4.0" 3 | attrs==24.3.0 ; python_version >= "3.10" and python_version < "4.0" 4 | certifi==2024.12.14 ; python_version >= "3.10" and python_version < "4.0" 5 | charset-normalizer==3.4.1 ; python_version >= "3.10" and python_version < "4.0" 6 | colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32" 7 | confusable-homoglyphs==3.3.1 ; python_version >= "3.10" and python_version < "4.0" 8 | decorator==5.1.1 ; python_version >= "3.10" and python_version < "4.0" 9 | dj-database-url==2.3.0 ; python_version >= "3.10" and python_version < "4.0" 10 | dj-rest-auth==7.0.1 ; python_version >= "3.10" and python_version < "4.0" 11 | django-cache-url==3.4.5 ; python_version >= "3.10" and python_version < "4.0" 12 | django-cors-headers==4.6.0 ; python_version >= "3.10" and python_version < "4.0" 13 | django-registration==5.1.0 ; python_version >= "3.10" and python_version < "4.0" 14 | django-rosetta==0.10.1 ; python_version >= "3.10" and python_version < "4.0" 15 | django==5.1.5 ; python_version >= "3.10" and python_version < "4.0" 16 | djangorestframework-dataclasses==1.3.1 ; python_version >= "3.10" and python_version < "4.0" 17 | djangorestframework-simplejwt==5.4.0 ; python_version >= "3.10" and python_version < "4.0" 18 | djangorestframework==3.15.2 ; python_version >= "3.10" and python_version < "4.0" 19 | drf-spectacular==0.28.0 ; python_version >= "3.10" and python_version < "4.0" 20 | exceptiongroup==1.2.2 ; python_version >= "3.10" and python_version < "3.11" 21 | executing==2.1.0 ; python_version >= "3.10" and python_version < "4.0" 22 | idna==3.10 ; python_version >= "3.10" and python_version < "4.0" 23 | inflection==0.5.1 ; python_version >= "3.10" and python_version < "4.0" 24 | ipython==8.31.0 ; python_version >= "3.10" and python_version < "4.0" 25 | jedi==0.19.2 ; python_version >= "3.10" and python_version < "4.0" 26 | jsonschema-specifications==2024.10.1 ; python_version >= "3.10" and python_version < "4.0" 27 | jsonschema==4.23.0 ; python_version >= "3.10" and python_version < "4.0" 28 | matplotlib-inline==0.1.7 ; python_version >= "3.10" and python_version < "4.0" 29 | parso==0.8.4 ; python_version >= "3.10" and python_version < "4.0" 30 | pexpect==4.9.0 ; python_version >= "3.10" and python_version < "4.0" and (sys_platform != "win32" and sys_platform != "emscripten") 31 | polib==1.2.0 ; python_version >= "3.10" and python_version < "4.0" 32 | prompt-toolkit==3.0.48 ; python_version >= "3.10" and python_version < "4.0" 33 | psycopg2==2.9.10 ; python_version >= "3.10" and python_version < "4.0" 34 | ptyprocess==0.7.0 ; python_version >= "3.10" and python_version < "4.0" and (sys_platform != "win32" and sys_platform != "emscripten") 35 | pure-eval==0.2.3 ; python_version >= "3.10" and python_version < "4.0" 36 | pygments==2.19.1 ; python_version >= "3.10" and python_version < "4.0" 37 | pyjwt==2.10.1 ; python_version >= "3.10" and python_version < "4.0" 38 | python-decouple==3.8 ; python_version >= "3.10" and python_version < "4.0" 39 | pyyaml==6.0.2 ; python_version >= "3.10" and python_version < "4.0" 40 | referencing==0.36.1 ; python_version >= "3.10" and python_version < "4.0" 41 | requests==2.32.3 ; python_version >= "3.10" and python_version < "4.0" 42 | rpds-py==0.22.3 ; python_version >= "3.10" and python_version < "4.0" 43 | sqlparse==0.5.3 ; python_version >= "3.10" and python_version < "4.0" 44 | stack-data==0.6.3 ; python_version >= "3.10" and python_version < "4.0" 45 | traitlets==5.14.3 ; python_version >= "3.10" and python_version < "4.0" 46 | typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "4.0" 47 | tzdata==2024.2 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32" 48 | uritemplate==4.1.1 ; python_version >= "3.10" and python_version < "4.0" 49 | urllib3==2.3.0 ; python_version >= "3.10" and python_version < "4.0" 50 | wcwidth==0.2.13 ; python_version >= "3.10" and python_version < "4.0" 51 | -------------------------------------------------------------------------------- /node/src/js/sk.mood.form.js: -------------------------------------------------------------------------------- 1 | import { Toast } from "bootstrap"; 2 | 3 | export class MoodForm { 4 | constructor(apiUrls) { 5 | this.apiUrls = apiUrls; 6 | 7 | if (document.querySelectorAll(".js-btn").length > 0) { 8 | this.loadEntries(); 9 | this.addInputListener(); 10 | } 11 | } 12 | 13 | loadEntries() { 14 | let url = this.apiUrls["api-mood-table"]; 15 | const startDt = this.getQueryVariable("start_dt"); 16 | if (startDt) { 17 | url = `${url}?start_dt=${startDt}`; 18 | } 19 | 20 | const request = new Request(url, { 21 | method: "GET", 22 | headers: { 23 | "content-type": "application/json", 24 | }, 25 | mode: "same-origin", // Do not send CSRF token to another domain. 26 | }); 27 | fetch(request) 28 | .then((response) => response.json()) 29 | .then(function (response) { 30 | response.days_of_week.forEach((entry) => { 31 | for (let period of ["night", "day"]) { 32 | const key = `mood_${period}`; 33 | if (!entry[key]) { 34 | return; 35 | } 36 | const checkbox = document.querySelector( 37 | `[data-mood="${entry[key]}"][data-day="${entry.day}"][data-period="${period}"]` 38 | ); 39 | checkbox.checked = true; 40 | checkbox.setAttribute("data-active", checkbox.checked); 41 | document.querySelector( 42 | `#label-${period}-${entry[key]}-${entry.day}` 43 | ).innerText = "X"; 44 | } 45 | }); 46 | }); 47 | } 48 | 49 | addInputListener() { 50 | document.querySelectorAll(".mood-btn-label").forEach((label) => { 51 | label.addEventListener("click", () => { 52 | const checkbox = label.previousElementSibling; 53 | const csrfToken = document.querySelector( 54 | "[name=csrfmiddlewaretoken]" 55 | ).value; 56 | const request = new Request(this.apiUrls["api-entry-day"], { 57 | method: "POST", 58 | headers: { 59 | "X-CSRFToken": csrfToken, 60 | "content-type": "application/json", 61 | }, 62 | mode: "same-origin", // Do not send CSRF token to another domain. 63 | body: JSON.stringify({ 64 | period: checkbox.dataset.period, 65 | day: checkbox.dataset.day, 66 | mood: checkbox.dataset.mood, 67 | }), 68 | }); 69 | fetch(request).then(function () { 70 | new Array(...label.parentElement.childNodes.entries()) 71 | .map((elem) => elem[1]) 72 | .filter((elem) => elem.nodeName === "LABEL") 73 | .forEach((elem) => (elem.innerHTML = " ")); 74 | if (checkbox.dataset.active === "true") { 75 | // Remove entry 76 | checkbox.checked = false; 77 | checkbox.removeAttribute("checked"); 78 | checkbox.dataset.active = "false"; 79 | 80 | document.querySelector( 81 | "#mood-entry-posted-body" 82 | ).textContent = `Removed mood`; 83 | } else { 84 | // Set entry selected 85 | checkbox.setAttribute("data-active", true); 86 | label.innerText = "X"; 87 | 88 | document.querySelector( 89 | "#mood-entry-posted-body" 90 | ).textContent = `Saved mood`; 91 | } 92 | // Display a toast to signal the entry has been saved 93 | new Toast(document.querySelector("#mood-entry-posted"), { 94 | delay: 1500, 95 | }).show(); 96 | }); 97 | }); 98 | }); 99 | } 100 | 101 | getQueryVariable(name) { 102 | const query = window.location.search.substring(1); 103 | const vars = query.split("&"); 104 | for (var i = 0; i < vars.length; i++) { 105 | let pair = vars[i].split("="); 106 | if (pair[0] == name) { 107 | return pair[1]; 108 | } 109 | } 110 | return false; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /web/templates/web/search/search.html: -------------------------------------------------------------------------------- 1 | {% extends 'web/base.html' %} 2 | {% load i18n %} 3 | {% block title %} 4 | {% translate 'search' %} 5 | {% endblock %} 6 | {% block content %} 7 | {% load i18n %} 8 | 11 |
12 |
13 | 14 |
15 | 22 |
23 |
24 |
25 | 26 |
27 | 34 |
35 |
36 |
37 | 38 |
39 | 45 |
46 |
47 |
48 | 49 |
50 | 59 |
60 |
61 |
62 |
63 | 64 |
65 |
66 |
67 |
68 | {% for week in object_list %} 69 |
70 |
71 | {% translate 'week' %} #{{ week.week_date|date:'W' }} 73 | 74 | {{ week.week_date }} 75 | — 76 | {{ week.week_date|week_end_date }} 77 | 78 |
79 |
80 |
{% if week.note %}{{ week.note }}{% endif %}
81 |
82 |
83 | Graph 85 |
86 |
87 | {% endfor %} 88 | {% if paginator.num_pages > 1 %} 89 |
{% include 'web/shared/pagination.html' %}
90 | {% endif %} 91 | {% endblock %} 92 | -------------------------------------------------------------------------------- /node/src/js/sk.calendar.js: -------------------------------------------------------------------------------- 1 | import Calendar from "js-year-calendar"; 2 | import { Popover } from "bootstrap"; 3 | import "js-year-calendar/dist/js-year-calendar.css"; 4 | 5 | export class SkCalendar { 6 | constructor(apiUrls, catalog) { 7 | this.apiUrls = apiUrls; 8 | this.catalog = catalog; 9 | this.moodMapping = JSON.parse( 10 | document.getElementById("mood_mapping").textContent 11 | ); 12 | this.moodColors = JSON.parse( 13 | document.getElementById("mood_colors").textContent 14 | ); 15 | this.loadEntries().then((response) => { 16 | const calendarData = this.buildCalendarEntries(response); 17 | this.buildCalendar(calendarData); 18 | }); 19 | } 20 | 21 | buildCalendarEntries(calendarData) { 22 | calendarData.entries.map((item) => { 23 | const parts = item.day.split("-"); 24 | // Please pay attention to the month (parts[1]); JavaScript counts months from 0: 25 | // January - 0, February - 1, etc. 26 | const day = new Date(parts[0], parts[1] - 1, parts[2]); 27 | item.startDate = day; 28 | item.endDate = day; 29 | return item; 30 | }); 31 | return calendarData; 32 | } 33 | 34 | formatDate(dateStr) { 35 | const event = new Date(dateStr); 36 | const options = { 37 | weekday: "long", 38 | year: "numeric", 39 | month: "long", 40 | day: "numeric", 41 | }; 42 | return event.toLocaleDateString(undefined, options); 43 | } 44 | 45 | loadEntries() { 46 | return fetch(`${this.apiUrls["api-calendar"]}`) 47 | .then((response) => response.json()) 48 | .catch((err) => { 49 | console.error("Error", err); 50 | document.getElementById("#error-card").classList.remove("invisible"); 51 | document.getElementById("#error-msg").textContent = err.statusText; 52 | }); 53 | } 54 | 55 | buildCalendar(calendarData) { 56 | new Calendar("#calendar", { 57 | minDate: new Date(calendarData.first_day), 58 | maxDate: new Date(calendarData.last_day), 59 | style: "custom", 60 | customDataSourceRenderer: (ele, renderDate, eventList) => { 61 | let dayColor = "transparent"; 62 | let nightColor = "transparent"; 63 | ele = ele.parentElement; // We are passed the child of the element to be styled 64 | if (eventList[0].startDate.getTime() == renderDate.getTime()) { 65 | if (eventList[0].mood_day) { 66 | dayColor = this.moodColors[eventList[0].mood_day]; 67 | } 68 | if (eventList[0].mood_night) { 69 | nightColor = this.moodColors[eventList[0].mood_night]; 70 | } 71 | } 72 | ele.style.cssText = `border-bottom: 3px solid ${dayColor}; 73 | border-left: 3px solid ${dayColor}; 74 | border-top: 3px solid ${nightColor}; 75 | border-right: 3px solid ${nightColor}; 76 | `; 77 | }, 78 | dataSource: calendarData.entries, 79 | mouseOnDay: (e) => { 80 | if (e.events.length > 0) { 81 | let content = ""; 82 | 83 | for (let i in e.events) { 84 | content += ` 85 |
86 |

${this.formatDate(e.events[i].day)}

87 |
    88 |
  • 89 | ${this.catalog["mood"]} ${this.catalog["night"]}: ${ 90 | this.moodMapping[e.events[i].mood_night] 91 | } 92 |
  • 93 |
  • 94 | ${this.catalog["mood"]} ${this.catalog["day"]}: ${ 95 | this.moodMapping[e.events[i].mood_day] 96 | } 97 |
  • 98 |
99 |
100 | `; 101 | } 102 | 103 | const popover = new Popover(e.element, { 104 | trigger: "manual", 105 | container: "body", 106 | html: true, 107 | content: content, 108 | }); 109 | 110 | popover.show(); 111 | } 112 | }, 113 | mouseOutDay: function (e) { 114 | if (e.events.length > 0) { 115 | Popover.getInstance(e.element).hide(); 116 | } 117 | }, 118 | clickDay: (e) => { 119 | const skDateStr = 120 | e.date.getFullYear() + 121 | "-" + 122 | ("0" + (e.date.getMonth() + 1)).slice(-2) + 123 | "-" + 124 | ("0" + e.date.getDate()).slice(-2); 125 | const url = `${this.siteUrl}?start_dt=${skDateStr}`; 126 | window.location.href = url; 127 | }, 128 | }); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /node/src/js/sk.graph.js: -------------------------------------------------------------------------------- 1 | // const Plotly = require('plotly.js/lib/index-basic') 2 | 3 | export class Graph { 4 | constructor(apiUrls, catalog) { 5 | this.apiUrls = apiUrls; 6 | this.catalog = catalog; 7 | this.moodMapping = JSON.parse( 8 | document.querySelector("#mood_mapping").textContent 9 | ); 10 | this.graphBase = document.getElementById("sk-graph"); 11 | this.textColor = getComputedStyle( 12 | document.documentElement 13 | ).getPropertyValue("--bs-body-color"); 14 | 15 | this.buildScatterPlot(); 16 | 17 | this.getMoodColors().then((moodColors) => { 18 | // console.log(moodColors) 19 | this.buildPieChart(moodColors); 20 | }); 21 | 22 | this.buildBarChart(); 23 | } 24 | 25 | fetchScatterPlotData() { 26 | const url = `${this.apiUrls["api-scatter-plot"]}?start_dt=${this.graphBase.dataset.startDt}&end_dt=${this.graphBase.dataset.endDt}`; 27 | return fetch(url) 28 | .then((response) => response.json()) 29 | .catch((err) => { 30 | console.error("Error", err); 31 | }); 32 | } 33 | 34 | buildScatterPlot() { 35 | this.fetchScatterPlotData().then((data) => { 36 | const traces = []; 37 | for (let period of ["day", "night"]) { 38 | traces.push({ 39 | name: this.catalog[period], 40 | x: data.map((elem) => elem["x"]), 41 | y: data.map((elem) => elem["y"][period]), 42 | mode: 43 | this.graphBase.dataset.isMarkers === "True" ? "markers" : "lines", 44 | type: "scatter", 45 | line: { 46 | shape: "spline", 47 | }, 48 | }); 49 | } 50 | const layout = { 51 | yaxis: { 52 | range: [0, 5], 53 | tickmode: "array", 54 | ticktext: Object.values(this.moodMapping), 55 | tickvals: Object.keys(this.moodMapping), 56 | }, 57 | paper_bgcolor: "rgba(0,0,0,0)", 58 | plot_bgcolor: "rgba(0,0,0,0)", 59 | font: { 60 | color: this.textColor, 61 | }, 62 | showlegend: false, 63 | }; 64 | Plotly.newPlot("scatter-plot", traces, layout, { displayModeBar: false }); 65 | }); 66 | } 67 | 68 | buildPieChart(moodColors) { 69 | const requests = []; 70 | for (let period of ["mood_day", "mood_night"]) { 71 | const url = `${this.apiUrls["api-pie-chart"]}?start_dt=${this.graphBase.dataset.startDt}&end_dt=${this.graphBase.dataset.endDt}&period=${period}`; 72 | requests.push(fetch(url).then((response) => response.json())); 73 | } 74 | Promise.all(requests).then((responses) => { 75 | // console.log("responses", responses); 76 | const periods = ["day", "night"]; 77 | for (let index in responses) { 78 | // console.log(this.catalog) 79 | // console.log(responses) 80 | // console.log(responses[index]); 81 | // console.log(responses[index]["label_numbers"]) 82 | const data = { 83 | type: "pie", 84 | values: responses[index]["values"], 85 | labels: responses[index]["label_numbers"].map( 86 | (iter) => this.moodMapping[iter] 87 | ), 88 | textinfo: "label+percent", 89 | hole: 0.4, 90 | hoverinfo: "label+percent", 91 | domain: { 92 | row: 0, 93 | column: index, 94 | }, 95 | name: this.catalog[periods[index]], 96 | marker: { 97 | colors: responses[index]["label_numbers"].map( 98 | (elem) => moodColors.find((i) => i.mood === elem)["color"] 99 | ), 100 | }, 101 | }; 102 | const layout = { 103 | annotations: [ 104 | { 105 | text: this.catalog[periods[index]], 106 | showarrow: false, 107 | font: { 108 | size: 20, 109 | }, 110 | }, 111 | ], 112 | paper_bgcolor: "rgba(0,0,0,0)", 113 | plot_bgcolor: "rgba(0,0,0,0)", 114 | font: { 115 | color: this.textColor, 116 | }, 117 | showlegend: false, 118 | }; 119 | Plotly.newPlot(`pie-chart-${index}`, [data], layout, { 120 | displayModeBar: false, 121 | }); 122 | } 123 | }); 124 | } 125 | 126 | buildBarChart() { 127 | const url = `${this.apiUrls["api-bar-chart"]}?start_dt=${this.graphBase.dataset.startDt}&end_dt=${this.graphBase.dataset.endDt}`; 128 | fetch(url) 129 | .then((response) => response.json()) 130 | .then((data) => { 131 | const plotData = [ 132 | { 133 | x: data["labels"], 134 | y: data["values"], 135 | type: "bar", 136 | }, 137 | ]; 138 | const layout = { 139 | yaxis: { 140 | range: [0, 5], 141 | tickmode: "array", 142 | ticktext: Object.values(this.moodMapping), 143 | tickvals: Object.keys(this.moodMapping), 144 | }, 145 | paper_bgcolor: "rgba(0,0,0,0)", 146 | plot_bgcolor: "rgba(0,0,0,0)", 147 | font: { 148 | color: this.textColor, 149 | }, 150 | showlegend: false, 151 | }; 152 | Plotly.newPlot("bar-chart", plotData, layout); 153 | }); 154 | } 155 | 156 | getMoodColors() { 157 | return fetch(this.apiUrls["api-mood-colors"]).then((response) => 158 | response.json() 159 | ); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /web/templates/web/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% load static %} 5 | {% load i18n %} 6 | 7 | 9 | 10 | 11 | {% block title %} 12 | {% endblock title %} 13 | · Stimmungskalender 14 | 15 | 16 | 19 | 23 | 27 | 28 | 29 | 30 | 31 | 32 | {% if user.is_authenticated %} 33 | 76 | {% endif %} 77 |
78 | {% block content %} 79 | {% endblock content %} 80 | {% include 'web/shared/i18n.html' %} 81 |
82 | {% if user.is_authenticated %} 83 | 93 | {% endif %} 94 | {% block js %} 95 | {% endblock js %} 96 | {{ api_urls|json_script:"api_urls" }} 97 | {{ mood_mapping|json_script:"mood_mapping" }} 98 | {{ mood_colors|json_script:"mood_colors" }} 99 | {{ active_url|json_script:"active_url" }} 100 | {% get_current_language as language_code %} 101 | {{ language_code|json_script:"current_language" }} 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /stimmungskalender/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for stimmungskalender project. 3 | 4 | Generated by 'django-admin startproject' using Django 5.1.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/5.1/ref/settings/ 11 | """ 12 | 13 | from datetime import timedelta 14 | from pathlib import Path 15 | 16 | from decouple import Csv, config 17 | from dj_database_url import parse as db_url 18 | from django.utils.translation import gettext_lazy as _ 19 | 20 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 21 | BASE_DIR = Path(__file__).resolve().parent.parent 22 | 23 | 24 | # Quick-start development settings - unsuitable for production 25 | # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ 26 | 27 | # SECURITY WARNING: keep the secret key used in production secret! 28 | SECRET_KEY = config("SECRET_KEY", default="PLEASE-CHANGE-ME") 29 | 30 | # SECURITY WARNING: don't run with debug turned on in production! 31 | DEBUG = config("DEBUG", default=False, cast=bool) 32 | 33 | ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="127.0.0.1", cast=Csv()) 34 | 35 | # Application definition 36 | 37 | INSTALLED_APPS = [ 38 | "web.apps.WebConfig", 39 | "django.contrib.admin", 40 | "django.contrib.auth", 41 | "django.contrib.contenttypes", 42 | "django.contrib.sessions", 43 | "django.contrib.messages", 44 | "django.contrib.staticfiles", 45 | "rosetta", 46 | "django_registration", 47 | "rest_framework", 48 | "rest_framework.authtoken", 49 | "corsheaders", 50 | ] 51 | 52 | MIDDLEWARE = [ 53 | "django.middleware.security.SecurityMiddleware", 54 | "django.contrib.sessions.middleware.SessionMiddleware", 55 | "django.middleware.locale.LocaleMiddleware", 56 | "corsheaders.middleware.CorsMiddleware", 57 | "django.middleware.common.CommonMiddleware", 58 | "django.middleware.csrf.CsrfViewMiddleware", 59 | "django.contrib.auth.middleware.AuthenticationMiddleware", 60 | "django.contrib.messages.middleware.MessageMiddleware", 61 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 62 | ] 63 | 64 | ROOT_URLCONF = "stimmungskalender.urls" 65 | 66 | TEMPLATES = [ 67 | { 68 | "BACKEND": "django.template.backends.django.DjangoTemplates", 69 | "DIRS": [], 70 | "APP_DIRS": True, 71 | "OPTIONS": { 72 | "context_processors": [ 73 | "django.template.context_processors.debug", 74 | "django.template.context_processors.request", 75 | "django.contrib.auth.context_processors.auth", 76 | "django.contrib.messages.context_processors.messages", 77 | "web.context_processors.lang", 78 | "web.context_processors.mood_colors", 79 | "web.context_processors.mood_names", 80 | "web.context_processors.api_urls", 81 | "web.context_processors.active_url", 82 | ], 83 | }, 84 | }, 85 | ] 86 | 87 | WSGI_APPLICATION = "stimmungskalender.wsgi.application" 88 | 89 | 90 | # Database 91 | # https://docs.djangoproject.com/en/5.1/ref/settings/#databases 92 | DATABASES = { 93 | "default": config( 94 | "DATABASE_URL", default=f"sqlite:///{BASE_DIR / 'db.sqlite3'}", cast=db_url 95 | ) 96 | } 97 | 98 | 99 | # Password validation 100 | # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators 101 | 102 | AUTH_PASSWORD_VALIDATORS = [ 103 | { 104 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 105 | }, 106 | { 107 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 108 | }, 109 | { 110 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 111 | }, 112 | { 113 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 114 | }, 115 | ] 116 | 117 | 118 | # Internationalization 119 | # https://docs.djangoproject.com/en/5.1/topics/i18n/ 120 | 121 | LANGUAGE_CODE = config("LANGUAGE_CODE", default="de-de") 122 | 123 | TIME_ZONE = config("TIME_ZONE", default="Europe/Berlin") 124 | 125 | USE_I18N = config("USE_I18N", default=True, cast=bool) 126 | 127 | USE_L10N = config("USE_L10N", default=True, cast=bool) 128 | 129 | USE_TZ = config("USE_TZ", default=True, cast=bool) 130 | 131 | FIRST_DAY_OF_WEEK = config("FIRST_DAY_OF_WEEK", default=1, cast=int) 132 | 133 | LANGUAGE_PATHS = [ 134 | BASE_DIR / "locale", # base folder where manage.py resides 135 | BASE_DIR / "simple/locale", # app folder 136 | ] 137 | 138 | LANGUAGES = [ 139 | ("de-DE", _("German")), 140 | ("en-GB", _("English")), 141 | ] 142 | 143 | 144 | # Static files (CSS, JavaScript, Images) 145 | # https://docs.djangoproject.com/en/5.1/howto/static-files/ 146 | 147 | STATIC_URL = "static/" 148 | 149 | STATIC_ROOT = config("STATIC_ROOT", default=None) 150 | 151 | # Default primary key field type 152 | # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field 153 | 154 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 155 | 156 | # Logging 157 | 158 | LOG_FILE_PATH = config("LOG_FILE_PATH", default=BASE_DIR / "stimmungskalender.log") 159 | 160 | LOGGING = { 161 | "version": 1, 162 | "disable_existing_loggers": True, 163 | "formatters": { 164 | "verbose": { 165 | "format": "[{levelname} {asctime} {module} {funcName}] {message}", 166 | "style": "{", 167 | }, 168 | }, 169 | "handlers": { 170 | "console": { 171 | "level": "DEBUG", 172 | "class": "logging.StreamHandler", 173 | "formatter": "verbose", 174 | }, 175 | "log_file": { 176 | "level": "DEBUG", 177 | "class": "logging.FileHandler", 178 | "formatter": "verbose", 179 | "filename": LOG_FILE_PATH, 180 | }, 181 | "mail_admins": { 182 | "level": "ERROR", 183 | "class": "django.utils.log.AdminEmailHandler", 184 | }, 185 | }, 186 | "loggers": { 187 | "django": {"handlers": ["log_file", "console"], "level": "INFO"}, 188 | "django.request": { 189 | "handlers": ["mail_admins"], 190 | "level": "ERROR", 191 | "propagate": False, 192 | }, 193 | }, 194 | } 195 | 196 | # Django Registration 197 | 198 | REGISTRATION_OPEN = config("REGISTRATION_OPEN", default=False, cast=bool) 199 | 200 | # Django Rest Framework 201 | 202 | REST_FRAMEWORK = { 203 | "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", 204 | "PAGE_SIZE": 7, 205 | "DEFAULT_AUTHENTICATION_CLASSES": ( 206 | "rest_framework.authentication.SessionAuthentication", 207 | "rest_framework.authentication.TokenAuthentication", 208 | ), 209 | } 210 | 211 | # dj rest auth 212 | 213 | REST_AUTH = { 214 | "USE_JWT": True, 215 | "JWT_AUTH_COOKIE": "sk-auth-cookie", 216 | "JWT_AUTH_REFRESH_COOKIE": "sk-refresh-token", 217 | "JWT_AUTH_HTTPONLY": False, 218 | } 219 | 220 | # Simple JWT 221 | 222 | SIMPLE_JWT = { 223 | "ACCESS_TOKEN_LIFETIME": timedelta(days=7), 224 | "REFRESH_TOKEN_LIFETIME": timedelta(days=7), 225 | } 226 | 227 | # Rosetta Settings 228 | 229 | ROSETTA_SHOW_AT_ADMIN_PANEL = True 230 | 231 | ROSETTA_MESSAGES_PER_PAGE = 100 232 | 233 | # Stimmungskalender Settings 234 | 235 | DEFAULT_VIEW_MODE = "lines" 236 | 237 | SK_DATE_FORMAT = "%Y-%m-%d" # To identify a week 238 | 239 | IS_WSGI = config("IS_WSGI", default=True, cast=bool) 240 | -------------------------------------------------------------------------------- /web/locale/en_GB/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2023-01-08 19:58+0100\n" 12 | "PO-Revision-Date: 2023-01-08 15:30+0100\n" 13 | "Last-Translator: <>\n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | "X-Translated-Using: django-rosetta 0.9.8\n" 21 | 22 | #: context_processors.py:30 service/sk.py:44 23 | msgid "very_bad" 24 | msgstr "Very bad" 25 | 26 | #: context_processors.py:31 service/sk.py:45 27 | msgid "bad" 28 | msgstr "Bad" 29 | 30 | #: context_processors.py:32 service/sk.py:46 31 | msgid "medium" 32 | msgstr "Medium" 33 | 34 | #: context_processors.py:33 service/sk.py:47 35 | msgid "good" 36 | msgstr "Good" 37 | 38 | #: context_processors.py:34 service/sk.py:48 39 | msgid "very_good" 40 | msgstr "Very good" 41 | 42 | #: service/pie_graph.py:59 service/scatter_graph.py:37 43 | msgid "day" 44 | msgstr "Day" 45 | 46 | #: service/pie_graph.py:60 service/scatter_graph.py:45 47 | msgid "night" 48 | msgstr "Night" 49 | 50 | #: templates/registration/login.html:15 51 | msgid "Log in" 52 | msgstr "" 53 | 54 | #: templates/web/base.html:47 templates/web/base.html:83 55 | #: templates/web/mood-form/entry_list.html:4 56 | msgid "home" 57 | msgstr "Home" 58 | 59 | #: templates/web/base.html:50 templates/web/graph/graph.html:5 60 | #: templates/web/graph/graph.html:15 61 | msgid "graphs" 62 | msgstr "Graphs" 63 | 64 | #: templates/web/base.html:53 templates/web/calendar/calendar.html:4 65 | #: templates/web/calendar/calendar.html:8 66 | msgid "calendar" 67 | msgstr "Calendar" 68 | 69 | #: templates/web/base.html:56 templates/web/search/search.html:4 70 | #: templates/web/search/search.html:10 71 | msgid "search" 72 | msgstr "Search" 73 | 74 | #: templates/web/base.html:59 templates/web/settings/settings.html:4 75 | #: templates/web/settings/settings.html:10 76 | msgid "settings" 77 | msgstr "Settings" 78 | 79 | #: templates/web/base.html:70 80 | msgid "logged_in_as" 81 | msgstr "Logged in as" 82 | 83 | #: templates/web/base.html:86 84 | msgid "logout" 85 | msgstr "Logout" 86 | 87 | #: templates/web/graph/date_select.html:7 templates/web/search/search.html:14 88 | msgid "start_date" 89 | msgstr "From" 90 | 91 | #: templates/web/graph/date_select.html:17 templates/web/search/search.html:26 92 | msgid "end_date" 93 | msgstr "To" 94 | 95 | #: templates/web/graph/date_select.html:27 96 | #: templates/web/mood-form/entry_list.html:55 97 | #: templates/web/search/search.html:68 98 | msgid "go_to" 99 | msgstr "Go" 100 | 101 | #: templates/web/graph/date_select.html:35 102 | msgid "from_start" 103 | msgstr "From start" 104 | 105 | #: templates/web/graph/date_select.html:41 106 | msgid "last_week" 107 | msgstr "Last week" 108 | 109 | #: templates/web/graph/date_select.html:47 110 | msgid "last_month" 111 | msgstr "Last month" 112 | 113 | #: templates/web/graph/date_select.html:53 114 | msgid "last_year" 115 | msgstr "Last year" 116 | 117 | #: templates/web/graph/graph.html:23 118 | msgid "scatterplot" 119 | msgstr "Scatterplot" 120 | 121 | #: templates/web/graph/graph.html:27 122 | msgid "pie_charts" 123 | msgstr "Pie Charts" 124 | 125 | #: templates/web/graph/graph.html:32 126 | msgid "average" 127 | msgstr "Average" 128 | 129 | #: templates/web/graph/pie_chart.html:4 templates/web/graph/scatter.html:5 130 | msgid "time_range" 131 | msgstr "Time range" 132 | 133 | #: templates/web/graph/scatter.html:13 134 | #: templates/web/settings/default_view_mode.html:10 135 | msgid "markers" 136 | msgstr "Markers" 137 | 138 | #: templates/web/graph/scatter.html:18 139 | #: templates/web/settings/default_view_mode.html:19 140 | msgid "lines" 141 | msgstr "Lines" 142 | 143 | #: templates/web/mood-form/entry_list.html:9 144 | msgid "diary" 145 | msgstr "Diary" 146 | 147 | #: templates/web/mood-form/entry_list.html:15 148 | #: templates/web/search/search.html:78 149 | msgid "week" 150 | msgstr "Week" 151 | 152 | #: templates/web/mood-form/entry_list.html:30 153 | msgid "note" 154 | msgstr "Note" 155 | 156 | #: templates/web/mood-form/entry_list.html:34 157 | msgid "prev" 158 | msgstr "Previous" 159 | 160 | #: templates/web/mood-form/entry_list.html:36 161 | msgid "next" 162 | msgstr "Next" 163 | 164 | #: templates/web/mood-form/entry_list.html:45 165 | msgid "jump_to_week" 166 | msgstr "Jump to week" 167 | 168 | #: templates/web/mood-form/entry_list.html:62 169 | msgid "days_tracked" 170 | msgstr "days tracked" 171 | 172 | #: templates/web/mood-form/entry_list.html:63 173 | msgid "nights_tracked" 174 | msgstr "nights tracked" 175 | 176 | #: templates/web/mood-form/form.html:15 177 | msgid "click_to_toggle" 178 | msgstr "Click to toggle" 179 | 180 | #: templates/web/mood-form/note.html:22 181 | msgid "what_happened_this_week" 182 | msgstr "What happened this week?" 183 | 184 | #: templates/web/mood-form/note.html:24 templates/web/settings/language.html:20 185 | #: templates/web/settings/settings.html:26 186 | msgid "save" 187 | msgstr "Save" 188 | 189 | #: templates/web/search/search.html:38 templates/web/search/search.html:44 190 | msgid "search_term" 191 | msgstr "Search term" 192 | 193 | #: templates/web/search/search.html:49 194 | msgid "mood" 195 | msgstr "Mood" 196 | 197 | #: templates/web/settings/default_view_mode.html:2 198 | msgid "default_view_mode" 199 | msgstr "Default view" 200 | 201 | #: templates/web/settings/js_btn.html:2 202 | msgid "use_js_btn" 203 | msgstr "Use JavaScript buttons" 204 | 205 | #: templates/web/settings/js_btn.html:9 206 | msgid "submit_form_via_js" 207 | msgstr "Submit data from the mood form via JavaScript" 208 | 209 | #: templates/web/settings/language.html:2 210 | msgid "language" 211 | msgstr "Language" 212 | 213 | #: templates/web/settings/language.html:24 214 | msgid "current_language" 215 | msgstr "Current language" 216 | 217 | #: templates/web/settings/mood_colors.html:2 218 | msgid "mood_colors" 219 | msgstr "Mood colors" 220 | 221 | #: templates/web/settings/mood_colors.html:4 222 | msgid "you_can_set_a_color_for_each_color" 223 | msgstr "You can set a color for each color" 224 | 225 | #: templates/web/settings/mood_colors.html:5 226 | msgid "examples" 227 | msgstr "Examples" 228 | 229 | #: templates/web/settings/mood_colors.html:28 230 | msgid "reset" 231 | msgstr "Reset" 232 | 233 | #: templates/web/settings/settings.html:29 234 | msgid "user_mgmt" 235 | msgstr "User management" 236 | 237 | #: templates/web/settings/settings.html:30 238 | msgid "add_edit_remove_user" 239 | msgstr "To add, edit and remove user, go to the" 240 | 241 | #: templates/web/settings/settings.html:49 242 | msgid "export_your_data" 243 | msgstr "Export your data" 244 | 245 | #: templates/web/settings/view_forms.html:2 246 | msgid "view_forms" 247 | msgstr "Displayed forms" 248 | 249 | #: templates/web/settings/view_forms.html:9 250 | msgid "view_night_form" 251 | msgstr "Night form" 252 | 253 | #: templates/web/settings/view_forms.html:17 254 | msgid "view_day_form" 255 | msgstr "Day form" 256 | 257 | #: templates/web/shared/i18n.html:5 258 | msgid "day_header" 259 | msgstr "My day was..." 260 | 261 | #: templates/web/shared/i18n.html:6 templates/web/shared/i18n.html:7 262 | msgid "night_header" 263 | msgstr "My night was..." 264 | 265 | #: templates/web/shared/i18n.html:8 266 | msgid "first_page" 267 | msgstr "First page" 268 | 269 | #: templates/web/shared/i18n.html:9 270 | msgid "items_per_page" 271 | msgstr "Items per page" 272 | 273 | #: templates/web/shared/i18n.html:10 274 | msgid "last_page" 275 | msgstr "Last page" 276 | 277 | #: templates/web/shared/i18n.html:11 278 | msgid "next_page" 279 | msgstr "Next page" 280 | 281 | #: templates/web/shared/i18n.html:12 282 | msgid "previous_page" 283 | msgstr "Previous page" 284 | 285 | #: templates/web/shared/i18n.html:13 286 | msgid "page" 287 | msgstr "Page" 288 | 289 | #: templates/web/shared/i18n.html:14 290 | msgid "colors_enabled" 291 | msgstr "Colors enabled" 292 | 293 | #: templates/web/shared/i18n.html:15 294 | msgid "colors_enabled_label" 295 | msgstr "Use a color for each mood." 296 | 297 | #: templates/web/shared/i18n.html:16 298 | msgid "last_very_good_day" 299 | msgstr "Last very good day" 300 | 301 | #: templates/web/shared/i18n.html:17 302 | msgid "last_very_bad_day" 303 | msgstr "Last very bad day" 304 | 305 | #: templates/web/shared/i18n.html:18 306 | msgid "last_very_good_night" 307 | msgstr "Last very good night" 308 | 309 | #: templates/web/shared/i18n.html:19 310 | msgid "last_very_bad_night" 311 | msgstr "Last very bad night" 312 | 313 | #: templates/web/shared/i18n.html:20 314 | msgid "no_results" 315 | msgstr "No results" 316 | 317 | #~ msgid "date_filter" 318 | #~ msgstr "Date filter" 319 | 320 | #~ msgid "graph" 321 | #~ msgstr "Graph" 322 | 323 | #~ msgid "pie_chart" 324 | #~ msgstr "Pie Chart" 325 | 326 | #~ msgid "day_moods" 327 | #~ msgstr "Day moods" 328 | 329 | #~ msgid "night_moods" 330 | #~ msgstr "Night moods" 331 | 332 | #~ msgid "loading" 333 | #~ msgstr "Loading" 334 | 335 | #~ msgid "js_entry_form" 336 | #~ msgstr "JavaScript form" 337 | 338 | #~ msgid "year" 339 | #~ msgstr "Year" 340 | 341 | #~ msgid "all" 342 | #~ msgstr "All" 343 | -------------------------------------------------------------------------------- /web/locale/de_DE/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2023-01-08 19:58+0100\n" 12 | "PO-Revision-Date: 2023-01-08 15:30+0100\n" 13 | "Last-Translator: <>\n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "X-Translated-Using: django-rosetta 0.9.8\n" 20 | 21 | #: context_processors.py:30 service/sk.py:44 22 | msgid "very_bad" 23 | msgstr "Sehr schlecht" 24 | 25 | #: context_processors.py:31 service/sk.py:45 26 | msgid "bad" 27 | msgstr "Schlecht" 28 | 29 | #: context_processors.py:32 service/sk.py:46 30 | msgid "medium" 31 | msgstr "Mittel" 32 | 33 | #: context_processors.py:33 service/sk.py:47 34 | msgid "good" 35 | msgstr "Gut" 36 | 37 | #: context_processors.py:34 service/sk.py:48 38 | msgid "very_good" 39 | msgstr "Sehr gut" 40 | 41 | #: service/pie_graph.py:59 service/scatter_graph.py:37 42 | msgid "day" 43 | msgstr "Tag" 44 | 45 | #: service/pie_graph.py:60 service/scatter_graph.py:45 46 | msgid "night" 47 | msgstr "Nacht" 48 | 49 | #: templates/registration/login.html:15 50 | msgid "Log in" 51 | msgstr "" 52 | 53 | #: templates/web/base.html:47 templates/web/base.html:83 54 | #: templates/web/mood-form/entry_list.html:4 55 | msgid "home" 56 | msgstr "Startseite" 57 | 58 | #: templates/web/base.html:50 templates/web/graph/graph.html:5 59 | #: templates/web/graph/graph.html:15 60 | msgid "graphs" 61 | msgstr "Diagramme" 62 | 63 | #: templates/web/base.html:53 templates/web/calendar/calendar.html:4 64 | #: templates/web/calendar/calendar.html:8 65 | msgid "calendar" 66 | msgstr "Kalender" 67 | 68 | #: templates/web/base.html:56 templates/web/search/search.html:4 69 | #: templates/web/search/search.html:10 70 | msgid "search" 71 | msgstr "Suche" 72 | 73 | #: templates/web/base.html:59 templates/web/settings/settings.html:4 74 | #: templates/web/settings/settings.html:10 75 | msgid "settings" 76 | msgstr "Einstellungen" 77 | 78 | #: templates/web/base.html:70 79 | msgid "logged_in_as" 80 | msgstr "Angemeldet als" 81 | 82 | #: templates/web/base.html:86 83 | msgid "logout" 84 | msgstr "Abmelden" 85 | 86 | #: templates/web/graph/date_select.html:7 templates/web/search/search.html:14 87 | msgid "start_date" 88 | msgstr "Von" 89 | 90 | #: templates/web/graph/date_select.html:17 templates/web/search/search.html:26 91 | msgid "end_date" 92 | msgstr "Bis" 93 | 94 | #: templates/web/graph/date_select.html:27 95 | #: templates/web/mood-form/entry_list.html:55 96 | #: templates/web/search/search.html:68 97 | msgid "go_to" 98 | msgstr "Los" 99 | 100 | #: templates/web/graph/date_select.html:35 101 | msgid "from_start" 102 | msgstr "Seit Anfang" 103 | 104 | #: templates/web/graph/date_select.html:41 105 | msgid "last_week" 106 | msgstr "Letzte Woche" 107 | 108 | #: templates/web/graph/date_select.html:47 109 | msgid "last_month" 110 | msgstr "Letzten Monat" 111 | 112 | #: templates/web/graph/date_select.html:53 113 | msgid "last_year" 114 | msgstr "Letztes Jahr" 115 | 116 | #: templates/web/graph/graph.html:23 117 | msgid "scatterplot" 118 | msgstr "Scatterplot" 119 | 120 | #: templates/web/graph/graph.html:27 121 | msgid "pie_charts" 122 | msgstr "Kuchendiagramme" 123 | 124 | #: templates/web/graph/graph.html:32 125 | msgid "average" 126 | msgstr "Durchschnitt" 127 | 128 | #: templates/web/graph/pie_chart.html:4 templates/web/graph/scatter.html:5 129 | msgid "time_range" 130 | msgstr "Zeitraum" 131 | 132 | #: templates/web/graph/scatter.html:13 133 | #: templates/web/settings/default_view_mode.html:10 134 | msgid "markers" 135 | msgstr "Punkte" 136 | 137 | #: templates/web/graph/scatter.html:18 138 | #: templates/web/settings/default_view_mode.html:19 139 | msgid "lines" 140 | msgstr "Linien" 141 | 142 | #: templates/web/mood-form/entry_list.html:9 143 | msgid "diary" 144 | msgstr "Tagebuch" 145 | 146 | #: templates/web/mood-form/entry_list.html:15 147 | #: templates/web/search/search.html:78 148 | msgid "week" 149 | msgstr "Woche" 150 | 151 | #: templates/web/mood-form/entry_list.html:30 152 | msgid "note" 153 | msgstr "Notiz" 154 | 155 | #: templates/web/mood-form/entry_list.html:34 156 | msgid "prev" 157 | msgstr "Zurück" 158 | 159 | #: templates/web/mood-form/entry_list.html:36 160 | msgid "next" 161 | msgstr "Vor" 162 | 163 | #: templates/web/mood-form/entry_list.html:45 164 | msgid "jump_to_week" 165 | msgstr "Springe zu Woche" 166 | 167 | #: templates/web/mood-form/entry_list.html:62 168 | msgid "days_tracked" 169 | msgstr "Tage eingetragen" 170 | 171 | #: templates/web/mood-form/entry_list.html:63 172 | msgid "nights_tracked" 173 | msgstr "Nächte eingetragen" 174 | 175 | #: templates/web/mood-form/form.html:15 176 | msgid "click_to_toggle" 177 | msgstr "Klicken, um zu verstecken" 178 | 179 | #: templates/web/mood-form/note.html:22 180 | msgid "what_happened_this_week" 181 | msgstr "Was geschah diese Woche?" 182 | 183 | #: templates/web/mood-form/note.html:24 templates/web/settings/language.html:20 184 | #: templates/web/settings/settings.html:26 185 | msgid "save" 186 | msgstr "Speichern" 187 | 188 | #: templates/web/search/search.html:38 templates/web/search/search.html:44 189 | msgid "search_term" 190 | msgstr "Suchbegriff" 191 | 192 | #: templates/web/search/search.html:49 193 | msgid "mood" 194 | msgstr "Stimmung" 195 | 196 | #: templates/web/settings/default_view_mode.html:2 197 | msgid "default_view_mode" 198 | msgstr "Standard-Ansicht" 199 | 200 | #: templates/web/settings/js_btn.html:2 201 | msgid "use_js_btn" 202 | msgstr "Verwende JavaScript-Buttons" 203 | 204 | #: templates/web/settings/js_btn.html:9 205 | msgid "submit_form_via_js" 206 | msgstr "Sende die Daten des Stimmung-Formulars via JavaScript" 207 | 208 | #: templates/web/settings/language.html:2 209 | msgid "language" 210 | msgstr "Sprache" 211 | 212 | #: templates/web/settings/language.html:24 213 | msgid "current_language" 214 | msgstr "Aktuelle Sprache" 215 | 216 | #: templates/web/settings/mood_colors.html:2 217 | msgid "mood_colors" 218 | msgstr "Stimmung-Farben" 219 | 220 | #: templates/web/settings/mood_colors.html:4 221 | msgid "you_can_set_a_color_for_each_color" 222 | msgstr "Es kann für jede Stimmung eine Farbe eingestellt werden" 223 | 224 | #: templates/web/settings/mood_colors.html:5 225 | msgid "examples" 226 | msgstr "Beispiele" 227 | 228 | #: templates/web/settings/mood_colors.html:28 229 | msgid "reset" 230 | msgstr "Zurücksetzen" 231 | 232 | #: templates/web/settings/settings.html:29 233 | msgid "user_mgmt" 234 | msgstr "Benutzerverwaltung" 235 | 236 | #: templates/web/settings/settings.html:30 237 | msgid "add_edit_remove_user" 238 | msgstr "Benutzer hinzufügen, bearbeiten und löschen:" 239 | 240 | #: templates/web/settings/settings.html:49 241 | msgid "export_your_data" 242 | msgstr "Alle Daten exportieren" 243 | 244 | #: templates/web/settings/view_forms.html:2 245 | msgid "view_forms" 246 | msgstr "Angezeigte Formulare" 247 | 248 | #: templates/web/settings/view_forms.html:9 249 | msgid "view_night_form" 250 | msgstr "Nacht-Formular" 251 | 252 | #: templates/web/settings/view_forms.html:17 253 | msgid "view_day_form" 254 | msgstr "Tag-Formular" 255 | 256 | #: templates/web/shared/i18n.html:5 257 | msgid "day_header" 258 | msgstr "Mein Tag war..." 259 | 260 | #: templates/web/shared/i18n.html:6 templates/web/shared/i18n.html:7 261 | msgid "night_header" 262 | msgstr "Meine Nacht war..." 263 | 264 | #: templates/web/shared/i18n.html:8 265 | msgid "first_page" 266 | msgstr "Erste Seite" 267 | 268 | #: templates/web/shared/i18n.html:9 269 | msgid "items_per_page" 270 | msgstr "Einträge pro Seite" 271 | 272 | #: templates/web/shared/i18n.html:10 273 | msgid "last_page" 274 | msgstr "Letzte Seite" 275 | 276 | #: templates/web/shared/i18n.html:11 277 | msgid "next_page" 278 | msgstr "Nächste Seite" 279 | 280 | #: templates/web/shared/i18n.html:12 281 | msgid "previous_page" 282 | msgstr "Vorherige Seite" 283 | 284 | #: templates/web/shared/i18n.html:13 285 | msgid "page" 286 | msgstr "Seite" 287 | 288 | #: templates/web/shared/i18n.html:14 289 | msgid "colors_enabled" 290 | msgstr "Aktiviere Farben" 291 | 292 | #: templates/web/shared/i18n.html:15 293 | msgid "colors_enabled_label" 294 | msgstr "Nutze eine Farbe für jede Stimmung." 295 | 296 | #: templates/web/shared/i18n.html:16 297 | msgid "last_very_good_day" 298 | msgstr "Letzter sehr guter Tag" 299 | 300 | #: templates/web/shared/i18n.html:17 301 | msgid "last_very_bad_day" 302 | msgstr "Letzter sehr schlechter Tag" 303 | 304 | #: templates/web/shared/i18n.html:18 305 | msgid "last_very_good_night" 306 | msgstr "Letzte sehr gute Nacht" 307 | 308 | #: templates/web/shared/i18n.html:19 309 | msgid "last_very_bad_night" 310 | msgstr "Letzte sehr schlechte Nacht" 311 | 312 | #: templates/web/shared/i18n.html:20 313 | msgid "no_results" 314 | msgstr "Keine Ergebnisse" 315 | 316 | #~ msgid "date_filter" 317 | #~ msgstr "Datum-Filter" 318 | 319 | #~ msgid "graph" 320 | #~ msgstr "Diagramm" 321 | 322 | #~ msgid "pie_chart" 323 | #~ msgstr "Kuchendiagramm" 324 | 325 | #~ msgid "day_moods" 326 | #~ msgstr "Tag-Stimmungen" 327 | 328 | #~ msgid "night_moods" 329 | #~ msgstr "Nacht-Stimmungen" 330 | 331 | #, python-format 332 | #~ msgid "last %(data)s day" 333 | #~ msgstr "Letzter %(data)s Tag" 334 | 335 | #~ msgid "loading" 336 | #~ msgstr "Lade" 337 | 338 | #~ msgid "js_entry_form" 339 | #~ msgstr "JavaScript-Form" 340 | 341 | #~ msgid "year" 342 | #~ msgstr "Jahr" 343 | 344 | #~ msgid "all" 345 | #~ msgstr "Alles" 346 | -------------------------------------------------------------------------------- /web/views.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import typing 3 | from datetime import date, datetime, timedelta 4 | 5 | import pkg_resources 6 | from django.conf import settings 7 | from django.contrib.auth import logout 8 | from django.contrib.auth.decorators import login_required 9 | from django.core.exceptions import BadRequest 10 | from django.http import HttpResponseNotFound 11 | from django.shortcuts import redirect 12 | from django.template import loader 13 | from django.urls import reverse_lazy 14 | from django.utils import timezone 15 | from django.utils.decorators import method_decorator 16 | from django.views import View 17 | from django.views.decorators.csrf import ensure_csrf_cookie 18 | from django.views.generic import RedirectView, TemplateView 19 | from django.views.generic.list import ListView 20 | 21 | from web.models import PERIODS 22 | from web.query_params import QP_END_DT, QP_MOOD, QP_SEARCH_TERM, QP_START_DT 23 | from web.service.base_graph import PERIOD_DAY, PERIOD_NIGHT 24 | from web.service.settings import SettingsService 25 | from web.service.sk import SkService 26 | 27 | 28 | def custom_page_not_found_view(request, exception): 29 | context = { 30 | "request": request, 31 | } 32 | t = loader.get_template("web/errors/404.html") 33 | return HttpResponseNotFound(t.render(context)) 34 | 35 | 36 | def custom_error_view(request, exception=None): 37 | type_, value, traceback = sys.exc_info() 38 | context = { 39 | "request": request, 40 | "exception": exception, 41 | "type_": str(type_), 42 | } 43 | t = loader.get_template("web/errors/500.html") 44 | return HttpResponseNotFound(t.render(context)) 45 | 46 | 47 | def custom_permission_denied_view(request, exception=None): 48 | context = { 49 | "request": request, 50 | } 51 | t = loader.get_template("web/errors/403.html") 52 | return HttpResponseNotFound(t.render(context)) 53 | 54 | 55 | def custom_bad_request_view(request, exception=None): 56 | context = { 57 | "request": request, 58 | } 59 | t = loader.get_template("web/errors/400.html") 60 | return HttpResponseNotFound(t.render(context)) 61 | 62 | 63 | class DefaultDateHandler: 64 | def default_start_dt( 65 | self, 66 | ) -> date: 67 | try: 68 | start_date = self.request.GET.get(QP_START_DT, "") 69 | start_dt = datetime.strptime(start_date, "%Y-%m-%d") 70 | except ValueError: 71 | start_dt = timezone.now() + timedelta(days=-7) 72 | return start_dt.date() 73 | 74 | def default_end_dt(self) -> date: 75 | try: 76 | end_date = self.request.GET.get(QP_END_DT, "") 77 | end_dt = datetime.strptime(end_date, "%Y-%m-%d").date() 78 | except ValueError: 79 | end_dt = timezone.now().date() 80 | return end_dt 81 | 82 | 83 | @method_decorator(login_required, name="dispatch") 84 | class SettingsView(TemplateView): 85 | template_name = "web/settings/settings.html" 86 | 87 | def get_context_data(self, **kwargs): 88 | ss = SettingsService(self.request.user) 89 | sk_version = pkg_resources.get_distribution("stimmungskalender").version 90 | 91 | context = super().get_context_data(**kwargs) 92 | context["default_view_mode"] = ss.get_default_view_mode() 93 | context["user_settings"] = ss.user_settings() 94 | context["user_colors_settings"] = ss.user_colors_settings() 95 | context["version"] = sk_version 96 | return context 97 | 98 | 99 | @method_decorator(login_required, name="dispatch") 100 | class SaveSettingsView(View): 101 | def post(self, request): 102 | ss = SettingsService(self.request.user) 103 | # If day and night form should be displayed 104 | view_day_form = request.POST.get("view_day_form", "") 105 | view_night_form = request.POST.get("view_night_form", "") 106 | ss.set_forms_displayed(day=view_day_form == "on", night=view_night_form == "on") 107 | 108 | # Set markers / lines 109 | view_mode = request.POST.get("default_view_mode", "") 110 | ss.set_markers(view_mode) 111 | 112 | # Reset / set custom mood colors 113 | reset = bool(request.POST.get("reset", False)) 114 | if reset: 115 | ss.save_user_colors_settings() 116 | else: 117 | ss.save_user_colors_settings(request.POST.dict()) 118 | 119 | # Set js_btn enabled / disabled 120 | use_js_btn = request.POST.get("use_js_btn", "") 121 | ss.set_use_js_btn(use_js_btn == "on") 122 | 123 | return redirect("settings") 124 | 125 | 126 | @method_decorator(login_required, name="dispatch") 127 | class EntryListView(TemplateView): 128 | """ 129 | The home page, displays the day and night form. 130 | """ 131 | 132 | template_name = "web/mood-form/entry_list.html" 133 | 134 | @method_decorator(ensure_csrf_cookie) 135 | def get(self, request, *args, **kwargs): 136 | return super().get(request, *args, **kwargs) 137 | 138 | def get_context_data(self, **kwargs): 139 | context = super().get_context_data(**kwargs) 140 | sk_service = SkService(self.request.user) 141 | 142 | ss = SettingsService(self.request.user) 143 | 144 | start_day_p = self.request.GET.get(QP_START_DT, "").strip() 145 | context["mood_table"] = sk_service.mood_table(start_day_p) 146 | context["moods"] = sk_service.mood_mapping 147 | context["forms"] = self.get_forms() 148 | context["standout_data"] = sk_service.standout_data() 149 | context["general_stats"] = sk_service.general_stats() 150 | context["js_btn"] = ss.is_use_js_btn() 151 | 152 | return context 153 | 154 | def get_forms(self) -> typing.List[dict]: 155 | ret = [] 156 | ret.append( 157 | {"name": "night", "name_header": "night_header", "attr": PERIOD_NIGHT} 158 | ) 159 | ret.append({"name": "day", "name_header": "day_header", "attr": PERIOD_DAY}) 160 | return ret 161 | 162 | 163 | @method_decorator(login_required, name="dispatch") 164 | class SaveMoodView(View): 165 | def post(self, request): 166 | entry = request.POST.get("entry", None) 167 | period = request.POST.get("period", None) 168 | 169 | if not entry or not period: 170 | raise BadRequest() 171 | 172 | if period not in PERIODS: 173 | raise BadRequest() 174 | 175 | data = entry.split("_") 176 | mood = data[0] 177 | day = data[1] 178 | 179 | sk_service = SkService(self.request.user) 180 | sk_service.save_entry(period, mood, day) 181 | start_day_p = datetime.strptime(day, "%Y-%m-%d").strftime( 182 | settings.SK_DATE_FORMAT 183 | ) 184 | return redirect(f"{reverse_lazy('index')}?start_dt={start_day_p}") 185 | 186 | 187 | class GraphView(DefaultDateHandler, TemplateView): 188 | template_name = "web/graph/graph.html" 189 | 190 | def get_context_data(self, **kwargs): 191 | context = super().get_context_data(**kwargs) 192 | ss = SettingsService(self.request.user) 193 | is_markers = ss.is_markers(self.request.GET) 194 | start_dt = self.default_start_dt() 195 | end_dt = self.default_end_dt() 196 | sk_service = SkService(self.request.user) 197 | 198 | context["start_dt"] = start_dt 199 | context["end_dt"] = end_dt 200 | context["is_markers"] = is_markers 201 | context["graph_time_ranges"] = sk_service.graph_time_ranges() 202 | context["mood_mapping"] = sk_service.mood_mapping 203 | return context 204 | 205 | 206 | @method_decorator(login_required, name="dispatch") 207 | class LogoutView(RedirectView): 208 | permanent = False 209 | url = reverse_lazy("login") 210 | 211 | def get(self, request, *args, **kwargs): 212 | logout(request) 213 | return super(LogoutView, self).get(request, *args, **kwargs) 214 | 215 | def dispatch(self, *args, **kwargs): 216 | return super(LogoutView, self).dispatch(*args, **kwargs) 217 | 218 | 219 | @method_decorator(login_required, name="dispatch") 220 | class SearchView(ListView): 221 | template_name = "web/search/search.html" 222 | paginate_by = 10 223 | 224 | def get_context_data(self, **kwargs): 225 | context = super().get_context_data(**kwargs) 226 | sk_service = SkService(self.request.user) 227 | context["moods"] = sk_service.mood_mapping 228 | return context 229 | 230 | def get_queryset(self): 231 | sk_service = SkService(self.request.user) 232 | return sk_service.search( 233 | mood=self.request.GET.get(QP_MOOD, ""), 234 | search_term=self.request.GET.get(QP_SEARCH_TERM, ""), 235 | start_dt=self.request.GET.get(QP_START_DT, ""), 236 | end_dt=self.request.GET.get(QP_END_DT, ""), 237 | ) 238 | 239 | 240 | @method_decorator(login_required, name="dispatch") 241 | class SaveNoteView(View): 242 | def post(self, request): 243 | week = request.POST.get("week", "").strip() 244 | note = request.POST.get("note", "").strip() 245 | 246 | if not week: 247 | raise BadRequest() 248 | 249 | sk_service = SkService(self.request.user) 250 | week = sk_service.save_note(week, note) 251 | start_day_p = week.week_date.strftime(settings.SK_DATE_FORMAT) 252 | return redirect(f"{reverse_lazy('index')}?{QP_START_DT}={start_day_p}") 253 | 254 | 255 | @method_decorator(login_required, name="dispatch") 256 | class CalendarView(TemplateView): 257 | template_name = "web/calendar/calendar.html" 258 | 259 | def get_context_data(self, **kwargs): 260 | context = super().get_context_data(**kwargs) 261 | context["mood_mapping"] = SkService(self.request.user).mood_mapping 262 | context["site_url"] = reverse_lazy("index") 263 | return context 264 | -------------------------------------------------------------------------------- /web/api.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import BadRequest 3 | from django.http import JsonResponse 4 | from django.utils import translation 5 | from django.views.i18n import JSONCatalog 6 | from rest_framework import status, views 7 | from rest_framework.generics import GenericAPIView 8 | from rest_framework.permissions import IsAuthenticated 9 | from rest_framework.response import Response 10 | 11 | from web import serializers 12 | from web.models import UserMoodColorSettings, UserSettings 13 | from web.query_params import QP_END_DT, QP_MOOD, QP_PERIOD, QP_SEARCH_TERM, QP_START_DT 14 | from web.service.bar_graph import BarGraphService 15 | from web.service.pie_graph import PieGraphService 16 | from web.service.scatter_graph import ScatterGraphService 17 | from web.service.settings import SettingsService 18 | from web.service.sk import SkService 19 | from web.views import DefaultDateHandler 20 | 21 | 22 | class EntryDayView(GenericAPIView): 23 | """ 24 | Set a mood for a single day and period. 25 | """ 26 | 27 | permission_classes = [IsAuthenticated] 28 | serializer_class = serializers.WeekdayEntrySerializer 29 | 30 | def post(self, request): 31 | sk_service = SkService(request.user) 32 | mood = request.data.get("mood", None) 33 | period = request.data.get("period", None) 34 | day = request.data.get("day", None) 35 | obj = sk_service.save_entry(period, mood, day) 36 | serialized_ret = serializers.WeekdayEntrySerializer(obj, data=request.data) 37 | if serialized_ret.is_valid(): 38 | return Response(serialized_ret.data, status=status.HTTP_201_CREATED) 39 | else: 40 | return Response(serialized_ret.errors, status=status.HTTP_400_BAD_REQUEST) 41 | 42 | 43 | class SaveNoteView(GenericAPIView): 44 | """ 45 | Save note for a week. 46 | """ 47 | 48 | permission_classes = [IsAuthenticated] 49 | serializer_class = serializers.WeekSerializer 50 | 51 | def post(self, request): 52 | week = request.data.get("week_date", None) 53 | note = request.data.get("note", "").strip() 54 | if not week: 55 | raise BadRequest() 56 | 57 | sk_service = SkService(request.user) 58 | week = sk_service.save_note(week, note) 59 | serialized_ret = serializers.WeekSerializer(week, data=request.data) 60 | if serialized_ret.is_valid(): 61 | return Response(serialized_ret.data, status=status.HTTP_201_CREATED) 62 | else: 63 | return Response(serialized_ret.errors, status=status.HTTP_400_BAD_REQUEST) 64 | 65 | 66 | class MoodTableView(GenericAPIView): 67 | """ 68 | Get data for a specific week. 69 | """ 70 | 71 | permission_classes = [IsAuthenticated] 72 | serializer_class = serializers.MoodTableSerializer 73 | 74 | def get(self, request): 75 | sk_service = SkService(request.user) 76 | start_day_p = request.GET.get(QP_START_DT, None) 77 | mood_table = sk_service.mood_table(start_day_p) 78 | serializer = serializers.MoodTableSerializer(mood_table) 79 | return Response(serializer.data) 80 | 81 | 82 | class SearchView(GenericAPIView): 83 | """ 84 | Provide endpoint to search for weeks. 85 | """ 86 | 87 | permission_classes = [IsAuthenticated] 88 | serializer_class = serializers.WeekSerializer 89 | 90 | def get(self, request): 91 | sk_service = SkService(request.user) 92 | start_dt = self.request.GET.get(QP_START_DT, "") 93 | end_dt = self.request.GET.get(QP_END_DT, "") 94 | results = sk_service.search( 95 | mood=self.request.GET.get(QP_MOOD, ""), 96 | search_term=self.request.GET.get(QP_SEARCH_TERM, ""), 97 | start_dt=start_dt, 98 | end_dt=end_dt, 99 | ) 100 | serializer = serializers.WeekSerializer(results, many=True) 101 | return Response(serializer.data) 102 | 103 | 104 | class SkJSONCatalog(GenericAPIView, JSONCatalog): 105 | authentication_classes = [] # disables authentication 106 | permission_classes = [] # disables permission 107 | 108 | def __init__(self, **kwargs): 109 | super().__init__(**kwargs) 110 | self.user_language = settings.LANGUAGE_CODE 111 | 112 | def check_permissions(self, request): 113 | super().check_permissions(request) 114 | 115 | def setup(self, request, *args, **kwargs): 116 | super().setup(request, *args, **kwargs) 117 | lang = self.request.GET.get("lang", "") 118 | if lang: 119 | self.user_language = lang 120 | translation.activate(self.user_language) 121 | 122 | def render_to_response(self, context, **response_kwargs): 123 | response = JsonResponse(context) 124 | response.set_cookie(settings.LANGUAGE_COOKIE_NAME, self.user_language) 125 | 126 | return response 127 | 128 | 129 | class SetLanguageView(views.APIView): 130 | """ 131 | Set the language of a user. 132 | """ 133 | 134 | permission_classes = [IsAuthenticated] 135 | 136 | def post(self, request): 137 | user_language = request.data.get("language", settings.LANGUAGE_CODE).strip() 138 | translation.activate(user_language) 139 | return Response(status=status.HTTP_200_OK) 140 | 141 | 142 | class StandoutDataView(GenericAPIView): 143 | """ 144 | Get the standout data. 145 | """ 146 | 147 | permission_classes = [IsAuthenticated] 148 | serializer_class = serializers.StandoutDataSerializer 149 | 150 | def get(self, request): 151 | sk_service = SkService(request.user) 152 | standout_data = sk_service.standout_data() 153 | serializer = serializers.StandoutDataSerializer(standout_data, many=True) 154 | return Response(serializer.data) 155 | 156 | 157 | class ScatterGraphView(DefaultDateHandler, GenericAPIView): 158 | """ 159 | Get the mood scatter graph. 160 | """ 161 | 162 | permission_classes = [IsAuthenticated] 163 | serializer_class = serializers.ScatterGraphResponseSerializer 164 | 165 | def get(self, request): 166 | sk_service = SkService(request.user) 167 | start_dt = self.default_start_dt() 168 | end_dt = self.default_end_dt() 169 | scatter_graph = ScatterGraphService( 170 | is_markers=True, 171 | mood_mapping=sk_service.mood_mapping, 172 | user=request.user, 173 | start_dt=start_dt, 174 | end_dt=end_dt, 175 | ) 176 | serializer = serializers.ScatterGraphResponseSerializer( 177 | scatter_graph.load_data(), many=True 178 | ) 179 | return Response(serializer.data) 180 | 181 | 182 | class PieChartGraphView(DefaultDateHandler, GenericAPIView): 183 | """ 184 | Get the mood scatter graph. 185 | """ 186 | 187 | permission_classes = [IsAuthenticated] 188 | serializer_class = serializers.PieChartResponseSerializer 189 | 190 | def get(self, request): 191 | sk_service = SkService(request.user) 192 | start_dt = self.default_start_dt() 193 | end_dt = self.default_end_dt() 194 | period = request.GET.get(QP_PERIOD, None) 195 | 196 | if not period: 197 | return Response(status=400) 198 | pie_graph = PieGraphService( 199 | user=request.user, 200 | mood_mapping=sk_service.mood_mapping, 201 | start_dt=start_dt, 202 | end_dt=end_dt, 203 | ) 204 | serializer = serializers.PieChartResponseSerializer(pie_graph.load_data(period)) 205 | return Response(serializer.data) 206 | 207 | 208 | class BarChartGraphView(DefaultDateHandler, GenericAPIView): 209 | """ 210 | Returns data for the "Average Moods" bar chart. 211 | """ 212 | 213 | permission_classes = [IsAuthenticated] 214 | serializer_class = serializers.BarChartResponseSerializer 215 | 216 | def get(self, request): 217 | sk_service = SkService(request.user) 218 | start_dt = self.default_start_dt() 219 | end_dt = self.default_end_dt() 220 | print(f"start_dt: {start_dt}") 221 | bar_graph = BarGraphService( 222 | user=self.request.user, 223 | mood_mapping=sk_service.mood_mapping, 224 | start_dt=start_dt, 225 | end_dt=end_dt, 226 | ) 227 | 228 | serializer = serializers.BarChartResponseSerializer(bar_graph.load_data()) 229 | return Response(serializer.data) 230 | 231 | 232 | class UserMoodColorSettingsView(GenericAPIView): 233 | permission_classes = [IsAuthenticated] 234 | serializer_class = serializers.UserMoodColorSettingsSerializer 235 | queryset = UserMoodColorSettings.objects.all() 236 | 237 | def get(self, request): 238 | sk_service = SettingsService(request.user) 239 | serializer = serializers.UserMoodColorSettingsSerializer( 240 | sk_service.user_colors_settings(), many=True 241 | ) 242 | return Response(serializer.data) 243 | 244 | 245 | class FormsDisplayedView(GenericAPIView): 246 | """ 247 | Enable and disable forms 248 | """ 249 | 250 | permission_classes = [IsAuthenticated] 251 | serializer_class = serializers.UserSettingsSerializer 252 | 253 | def get(self, request): 254 | user_settings = UserSettings.objects.get(user=request.user) 255 | serializer = serializers.UserSettingsSerializer(user_settings) 256 | return Response(serializer.data) 257 | 258 | def post(self, request): 259 | night_form = request.data.get("night_form") 260 | day_form = request.data.get("day_form") 261 | ss = SettingsService(request.user) 262 | ss.set_forms_displayed(day=day_form, night=night_form) 263 | return Response(status=status.HTTP_200_OK) 264 | 265 | 266 | class GraphView(GenericAPIView): 267 | """ 268 | General graph info 269 | """ 270 | 271 | permission_classes = [IsAuthenticated] 272 | serializer_class = serializers.GraphTimeRangesSerializer 273 | 274 | def get(self, request): 275 | sk_service = SkService(request.user) 276 | serializer = serializers.GraphTimeRangesSerializer( 277 | sk_service.graph_time_ranges() 278 | ) 279 | return Response(serializer.data) 280 | 281 | 282 | class CalendarView(GenericAPIView): 283 | """ 284 | Return data for the calendar view: all entries. 285 | """ 286 | 287 | permission_classes = [IsAuthenticated] 288 | serializer_class = serializers.CalendarSerializer 289 | 290 | def get(self, request): 291 | sk_service = SkService(request.user) 292 | serializer = serializers.CalendarSerializer(sk_service.calendar()) 293 | return Response(serializer.data) 294 | 295 | 296 | class ExportView(GenericAPIView): 297 | permission_classes = [IsAuthenticated] 298 | serializer_class = serializers.ExportDataSerializer 299 | 300 | def get(self, request): 301 | sk_service = SkService(request.user) 302 | serializer = serializers.ExportDataSerializer(sk_service.export()) 303 | return Response(serializer.data) 304 | -------------------------------------------------------------------------------- /web/service/sk.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from datetime import date, datetime, timedelta 3 | from enum import Enum 4 | 5 | from django.conf import settings 6 | from django.contrib.auth.models import User 7 | from django.db.models import Q, QuerySet 8 | from django.utils import timezone 9 | from django.utils.translation import gettext as _ 10 | 11 | from web.models import Entry, Moods, Week 12 | from web.service.base_graph import PERIOD_DAY, PERIOD_NIGHT 13 | from web.structs import ( 14 | ExportData, 15 | GeneralStats, 16 | GraphTimeRanges, 17 | MoodTable, 18 | SkCalendar, 19 | StandoutData, 20 | WeekdayEntry, 21 | ) 22 | 23 | 24 | class InvalidPeriodError(Exception): 25 | pass 26 | 27 | 28 | class MoodEntryError(Exception): 29 | pass 30 | 31 | 32 | class Period(Enum): 33 | DAY = "day" 34 | NIGHT = "night" 35 | 36 | 37 | class SkService: 38 | def __init__( 39 | self, 40 | user: User, 41 | ): 42 | self._user = user 43 | self.mood_mapping = { 44 | 1: _("very_bad"), 45 | 2: _("bad"), 46 | 3: _("medium"), 47 | 4: _("good"), 48 | 5: _("very_good"), 49 | } 50 | 51 | def export(self) -> ExportData: 52 | weeks = Week.objects.filter(user=self._user) 53 | return ExportData(entries=self.calendar(), moods=self.mood_mapping, weeks=weeks) 54 | 55 | def general_stats(self) -> GeneralStats: 56 | qs = Entry.objects.filter(user=self._user) 57 | day_count = qs.exclude(mood_day__isnull=True).count() 58 | night_count = qs.exclude(mood_night__isnull=True).count() 59 | gs = GeneralStats(day_count=day_count, night_count=night_count) 60 | return gs 61 | 62 | def calendar(self) -> SkCalendar: 63 | qs = Entry.objects.filter(user=self._user).order_by("-day") 64 | try: 65 | first_day = qs.last().day + timedelta(days=-1) 66 | last_day = qs.first().day + timedelta(days=1) 67 | except AttributeError: 68 | return SkCalendar( 69 | first_day=timezone.now().date(), 70 | last_day=timezone.now().date(), 71 | entries=[], 72 | ) 73 | entries = self._entries_range(first_day, last_day) 74 | data = SkCalendar( 75 | first_day=first_day, 76 | last_day=last_day, 77 | entries=entries, 78 | ) 79 | return data 80 | 81 | def search( 82 | self, 83 | search_term: str = "", 84 | start_dt: str = "", 85 | end_dt: str = "", 86 | mood: str = "", 87 | ) -> QuerySet: 88 | qs = Week.objects.filter(user=self._user).order_by("-week_date") 89 | qs = self._filter_search(qs, search_term) 90 | qs = self._filter_date(qs, start_dt, end_dt) 91 | qs = self._filter_mood(qs, mood) 92 | 93 | # Exclude future weeks 94 | qs = qs.exclude(week_date__gt=timezone.now()) 95 | return qs 96 | 97 | def mood_table(self, start_day_p: str) -> MoodTable: 98 | week_start = self._week_start(start_day_p) 99 | days_of_week = self._week_data(week_start) 100 | week = self._week(week_start) 101 | next_week = self._next_week(week_start) 102 | prev_week = self._prev_week(week_start) 103 | 104 | return MoodTable( 105 | days_of_week=days_of_week, 106 | week=week, 107 | next_week=next_week, 108 | prev_week=prev_week, 109 | ) 110 | 111 | def save_note(self, week: str, note: str) -> Week: 112 | """ 113 | :param week: Week in format YYYY-MM-DD, eg: 2022-04-18 114 | :param note: 115 | :return: 116 | """ 117 | week_date = datetime.strptime(week, settings.SK_DATE_FORMAT).date() 118 | week_date += timedelta(days=0 - week_date.weekday()) 119 | Week.objects.update_or_create( 120 | week_date=week_date, 121 | user=self._user, 122 | defaults={ 123 | "week_date": week_date, 124 | "note": note, 125 | "user": self._user, 126 | }, 127 | ) 128 | return Week(note=note, week_date=week_date) 129 | 130 | def save_entry(self, period: str, mood: int, day: str) -> WeekdayEntry: 131 | """Set or remove a mood""" 132 | 133 | form_mapping = { 134 | "night": PERIOD_NIGHT, 135 | "day": PERIOD_DAY, 136 | } 137 | 138 | # Create related week object 139 | dt1 = datetime.strptime(day, settings.SK_DATE_FORMAT) 140 | dt1 += timedelta(days=0 - dt1.weekday()) 141 | my_week, created = Week.objects.get_or_create(user=self._user, week_date=dt1) 142 | 143 | db_data = {"day": day, "user": self._user, "week": my_week} 144 | if period == "night": 145 | db_data[PERIOD_NIGHT] = mood 146 | elif period == "day": 147 | db_data[PERIOD_DAY] = mood 148 | else: 149 | raise MoodEntryError() 150 | 151 | if Entry.objects.filter(**db_data).count() == 1: 152 | # Click on saved mood: remove it 153 | Entry.objects.filter(**db_data).update(**{form_mapping[period]: None}) 154 | db_data.pop("mood_night", None) 155 | db_data.pop("mood_day", None) 156 | obj = Entry.objects.get(**db_data) 157 | else: 158 | obj, created = Entry.objects.update_or_create( 159 | day=day, week=my_week, user=self._user, defaults=db_data 160 | ) 161 | 162 | return WeekdayEntry( 163 | day=obj.day, mood_day=obj.mood_day, mood_night=obj.mood_night 164 | ) 165 | 166 | def standout_data(self) -> typing.List[StandoutData]: 167 | ret = [] 168 | qs = Entry.objects.filter(user=self._user) 169 | 170 | # very good day 171 | ret.append( 172 | StandoutData( 173 | label="last_very_good_day", 174 | css_class="standout-data-good", 175 | entry=(qs.filter(mood_day=Moods.VERY_GOOD).order_by("-id").first()), 176 | ) 177 | ) 178 | 179 | # very good night 180 | ret.append( 181 | StandoutData( 182 | label="last_very_good_night", 183 | css_class="standout-data-good", 184 | entry=(qs.filter(mood_night=Moods.VERY_GOOD).order_by("-id").first()), 185 | ) 186 | ) 187 | 188 | # very bad day 189 | ret.append( 190 | StandoutData( 191 | label="last_very_bad_day", 192 | css_class="standout-data-bad", 193 | entry=(qs.filter(mood_day=Moods.VERY_BAD).order_by("-id").first()), 194 | ) 195 | ) 196 | 197 | # very bad night 198 | ret.append( 199 | StandoutData( 200 | label="last_very_bad_night", 201 | css_class="standout-data-bad", 202 | entry=(qs.filter(mood_night=Moods.VERY_BAD).order_by("-id").first()), 203 | ) 204 | ) 205 | return ret 206 | 207 | def graph_time_ranges(self) -> GraphTimeRanges: 208 | return GraphTimeRanges( 209 | first_day=self._first_day(), 210 | last_week_start_dt=(timezone.now() + timedelta(days=-7)).strftime( 211 | "%Y-%m-%d" 212 | ), 213 | last_month_start_dt=(timezone.now() + timedelta(days=-30)).strftime( 214 | "%Y-%m-%d" 215 | ), 216 | last_year_start_dt=(timezone.now() + timedelta(days=-365)).strftime( 217 | "%Y-%m-%d" 218 | ), 219 | ) 220 | 221 | def _first_day(self) -> str: 222 | """Returns the date of the first mood entry. If the user has no entries, returns today's date""" 223 | qs = Entry.objects.filter(user=self._user).order_by("day") 224 | if qs.count() > 0: 225 | obj = qs.first() 226 | return obj.day.strftime("%Y-%m-%d") 227 | return timezone.now().strftime("%Y-%m-%d") 228 | 229 | def _week_data(self, first_day: date) -> typing.List[WeekdayEntry]: 230 | last_day = first_day + timedelta(days=7) 231 | ret = self._entries_range(first_day, last_day) 232 | return ret 233 | 234 | def _week(self, week_start: date) -> Week: 235 | # Make sure we use the start of the week 236 | week_start += timedelta(days=0 - week_start.weekday()) 237 | my_week, created = Week.objects.get_or_create( 238 | user=self._user, week_date=week_start 239 | ) 240 | return my_week 241 | 242 | def _next_week(self, week_start: date) -> str: 243 | ret = week_start + timedelta(days=0 - week_start.weekday() + 7) 244 | return ret.strftime(settings.SK_DATE_FORMAT) 245 | 246 | def _prev_week(self, week_start: date) -> str: 247 | ret = week_start + timedelta(days=0 - week_start.weekday() - 7) 248 | return ret.strftime(settings.SK_DATE_FORMAT) 249 | 250 | def _week_start(self, start_day_p: str = "") -> date: 251 | """ 252 | Returns the start of a week defined by YYYY-W format, eg: 2022-54 253 | :param start_day_p: 254 | :return: 255 | """ 256 | if start_day_p: 257 | dt1 = datetime.strptime(f"{start_day_p}", settings.SK_DATE_FORMAT) 258 | else: 259 | dt1 = timezone.now() 260 | dt1 += timedelta(days=0 - dt1.weekday()) 261 | return dt1.date() 262 | 263 | def _filter_mood( 264 | self, 265 | qs: QuerySet, 266 | mood_p: str = "", 267 | ) -> QuerySet: 268 | try: 269 | mood = int(mood_p) 270 | except ValueError: 271 | return qs 272 | if mood in Moods: 273 | qs = qs.filter(Q(entry__mood_day=mood) | Q(entry__mood_night=mood)) 274 | return qs 275 | 276 | def _filter_search( 277 | self, 278 | qs: QuerySet, 279 | search_term: str = "", 280 | ) -> QuerySet: 281 | search_term = search_term.strip() 282 | if search_term: 283 | qs = qs.filter(note__icontains=search_term) 284 | return qs 285 | 286 | def _filter_date( 287 | self, 288 | qs: QuerySet, 289 | start_date: str = "", 290 | end_date: str = "", 291 | ) -> QuerySet: 292 | start = start_date.strip() 293 | end = end_date.strip() 294 | if start: 295 | start_dt = datetime.strptime(start, settings.SK_DATE_FORMAT).date() 296 | qs = qs.filter(week_date__gte=start_dt) 297 | if end: 298 | end_dt = datetime.strptime(end, settings.SK_DATE_FORMAT).date() 299 | qs = qs.filter(week_date__lte=end_dt) 300 | return qs 301 | 302 | def _entries_range( 303 | self, first_day: date, last_day: date 304 | ) -> typing.List[WeekdayEntry]: 305 | """ 306 | Generates a list of WeekdayEntry objects for a date range. Fills empty days with empty data. 307 | :param first_day: Uses entries of this day or after 308 | :param last_day: Uses entries of this day or before 309 | :return: 310 | """ 311 | qs = ( 312 | Entry.objects.filter(user=self._user) 313 | .filter(day__gte=first_day, day__lt=last_day) 314 | .order_by("-day") 315 | ) 316 | delta = last_day - first_day 317 | days = [(first_day + timedelta(days=d)) for d in range(delta.days)] 318 | week_data = {} 319 | for day in days: 320 | week_data[day.strftime(settings.SK_DATE_FORMAT)] = WeekdayEntry( 321 | day=day, mood_day=None, mood_night=None 322 | ) 323 | for entry in qs: 324 | week_data[entry.day.strftime(settings.SK_DATE_FORMAT)] = WeekdayEntry( 325 | day=entry.day, mood_day=entry.mood_day, mood_night=entry.mood_night 326 | ) 327 | return [week_data[i] for i in week_data] 328 | --------------------------------------------------------------------------------