6 | DE: Eine Open-Source-Webanwendung zur Anwesenheitserfassung.
7 | Dient im Infektionsfall zur Kontaktverfolgung in Zeiten der Pandemie.
8 |
9 |
10 | EN: An open source web application for people to register their presence.
11 | Meant to help tracing contacts when someone got infected in pandemic times.
12 |
{% blocktrans %}We have sent an e-mail to you for verification. Follow the link provided to finalize the signup process. Please contact us if you do not receive it within a few minutes.{% endblocktrans %}
11 |
12 | {% endblock %}
13 |
14 |
--------------------------------------------------------------------------------
/anwesende/users/migrations/0002_alter_user_first_name.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.8 on 2021-11-12 16:28
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('users', '0001_initial'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name='user',
15 | name='first_name',
16 | field=models.CharField(blank=True, max_length=150, verbose_name='first name'),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/anwesende/room/migrations/0006_seat_rownumber.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.10 on 2020-12-30 12:51
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('room', '0005_number_to_seatnumber'),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name='seat',
15 | name='rownumber',
16 | field=models.IntegerField(default=1),
17 | preserve_default=False,
18 | ),
19 | ]
20 |
--------------------------------------------------------------------------------
/anwesende/room/management/commands/make_base_data.py:
--------------------------------------------------------------------------------
1 | import django.core.management.base as djcmb
2 |
3 | import anwesende.room.models as arm
4 | import anwesende.users.models as aum
5 |
6 |
7 | class Command(djcmb.BaseCommand):
8 | help = "Silently creates group 'datenverwalter'"
9 |
10 | def handle(self, *args, **options):
11 | dvgroup = aum.User.get_datenverwalter_group() # so admin has it on first visit
12 | print(f"created group '{dvgroup.name}'")
13 | arm.Seat.get_dummy_seat() # create now to make it nicely the first one
14 |
--------------------------------------------------------------------------------
/anwesende/templates/users/user_form.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% load crispy_forms_tags %}
3 |
4 | {% block title %}{{ user.username }}{% endblock %}
5 |
6 | {% block content %}
7 |
{% blocktrans %}This part of the site requires us to verify that
13 | you are who you claim to be. For this purpose, we require that you
14 | verify ownership of your e-mail address. {% endblocktrans %}
15 |
16 |
{% blocktrans %}We have sent an e-mail to you for
17 | verification. Please click on the link inside this e-mail. Please
18 | contact us if you do not receive it within a few minutes.{% endblocktrans %}
33 | {% endblock content %}
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | The MIT License (MIT)
3 | Copyright (c) 2020, Lutz Prechelt
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
11 |
--------------------------------------------------------------------------------
/anwesende/templates/account/email_confirm.html:
--------------------------------------------------------------------------------
1 | {% extends "account/base.html" %}
2 |
3 | {% load i18n %}
4 | {% load account %}
5 |
6 | {% block head_title %}{% trans "Confirm E-mail Address" %}{% endblock %}
7 |
8 |
9 | {% block inner %}
10 |
{% blocktrans with confirmation.email_address.email as email %}Please confirm that {{ email }} is an e-mail address for user {{ user_display }}.{% endblocktrans %}
{% if token_fail %}{% trans "Bad Token" %}{% else %}{% trans "Change Password" %}{% endif %}
9 |
10 | {% if token_fail %}
11 | {% url 'account_reset_password' as passwd_reset_url %}
12 |
{% blocktrans %}The password reset link was invalid, possibly because it has already been used. Please request a new password reset.{% endblocktrans %}
13 | {% else %}
14 | {% if form %}
15 |
20 | {% else %}
21 |
Danke für's Anmelden! / Thank you for registering!
5 |
6 | {% if with_seats %}
7 |
8 | DE:
9 | In diesem Raum, {{ room.room }}, ist/sind aktuell
10 | {{ visitors_presentN }} verschiedene Person(en) angemeldet.
11 |
12 |
13 | EN:
14 | Right now, {{ visitors_presentN }}
15 | different people is/are registered in this room {{ room.room }}.
16 |
17 |
18 | DE /
19 | EN:
20 |
21 | Diese Plätze sind registriert / The following seats have a registration:
22 | {% for s in seatlist %}
23 | {{ s }}
24 | {% if not forloop.last %}
25 | ,
26 | {% endif %}
27 | {% endfor %}
28 |
9 | Die bisherige Rechtsgrundlage für den Betrieb von a.nwesen.de
10 | ({{settings.LEGAL_BASIS_DE|safe}})
11 | ist aktuell entfallen.
12 | Bis eine neue Rechtsgrundlage erlassen wird,
13 | ist das System deshalb nur im Bereitschaftsmodus.
14 | Es ist aktuell nicht möglich, die eigene Anwesenheit zu registrieren.
15 |
16 |
EN: Not in Operation
17 |
18 | The previous legal basis for operating a.nwesen.de
19 | ({{settings.LEGAL_BASIS_EN|safe}})
20 | is no longer in force.
21 | Until a new legal basis is created by the lawmakers,
22 | the system therefore is in standby mode only.
23 | You cannot currently register your presence.
24 |
25 |
26 | {% else %}
27 |
28 | {% if not form.errors %}
29 | {% include "room/privacy.html" %}
30 | {% endif %}
31 | {% crispy form %}
32 |
33 |
34 | {% endif %}
35 | {% endblock content %}
36 |
--------------------------------------------------------------------------------
/config/envs/patch_env.py:
--------------------------------------------------------------------------------
1 | # Python script for rewriting a docker env file.
2 | # usage: patch_env dockerenv_input.env dockerenv_output.env
3 | # Copies each input line to the output, except when it is of the form
4 | # VAR_X=value
5 | # and an environment variable PATCH_VAR_X exists: then its value is used.
6 | # Performs no error handling.
7 |
8 | import os
9 | import re
10 | import sys
11 |
12 | PATCHVAR_PREFIX = "PATCH_"
13 |
14 |
15 | def patch_file(infile: str, outfile: str) -> None:
16 | with open(infile, 'rt', encoding='utf-8') as input, \
17 | open(outfile, 'wb') as output:
18 | for line in input:
19 | output.write(patched(line).encode('utf-8'))
20 |
21 |
22 | def patched(line: str) -> str:
23 | var_match = re.match(r"^(\w+)\s*=", line)
24 | if var_match:
25 | varname = var_match.group(1)
26 | patchvarname = PATCHVAR_PREFIX + varname
27 | patchvalue = os.environ.get(patchvarname, None)
28 | if patchvalue is None:
29 | return line # unpatched assignment line
30 | else:
31 | return "%s=%s\n" % (varname, patchvalue) # patched assignment line
32 | else:
33 | return line # non-assignment line
34 |
35 |
36 | if __name__ == '__main__':
37 | first = 2 if sys.argv[1].endswith('.py') else 1 # call: python patch_env.py in out
38 | patch_file(sys.argv[first], sys.argv[first + 1])
39 |
--------------------------------------------------------------------------------
/anwesende/utils/tests/test_date.py:
--------------------------------------------------------------------------------
1 | import datetime as dt
2 | import re
3 |
4 | import arrow
5 | import django.utils.timezone as djut
6 | import pytest
7 |
8 | import anwesende.utils.date as aud
9 |
10 |
11 | def test_nowstring():
12 | d_only = aud.nowstring()
13 | t_only = aud.nowstring(date=False, time=True)
14 | both = aud.nowstring(date=True, time=True)
15 | assert re.match(r"^\d\d\d\d-\d\d-\d\d$", d_only),\
16 | f"wrong d_only nowstring '{d_only}'"
17 | assert re.match(r"^\d\d:\d\d$", t_only),\
18 | f"wrong t_only nowstring '{t_only}'"
19 | assert re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d$", both),\
20 | f"wrong both nowstring '{both}'"
21 |
22 |
23 | def test_make_dt_with_tz():
24 | tzname = djut.get_current_timezone_name()
25 | now = djut.localtime()
26 | result = aud.make_dt(now, "12:34")
27 | print(f"TZ:{tzname}, now:{now.tzname()}, result:{result.tzname()}")
28 | print(result.isoformat())
29 | assert result.tzname() == now.tzname()
30 | assert result.day == now.day
31 | assert result.hour == 12
32 | assert result.minute == 34
33 | assert result.second == 0
34 |
35 |
36 | def test_make_dt_naive():
37 | with pytest.raises(AssertionError):
38 | aud.make_dt(dt.datetime.now(), "01:23")
39 |
40 |
41 | def test_make_dt_illformed():
42 | with pytest.raises(AssertionError) as ex:
43 | aud.make_dt(djut.localtime(), "23.45")
44 | assert "hh:mm" in str(ex.value)
45 |
--------------------------------------------------------------------------------
/anwesende/room/utils.py:
--------------------------------------------------------------------------------
1 | import django.template as djt
2 |
3 | register = djt.Library()
4 | # see https://docs.djangoproject.com/en/stable/howto/custom-template-tags/
5 |
6 | ALT_SLASH = '\N{DIVISION SLASH}'
7 |
8 |
9 | @register.filter
10 | def escape_slash(urlparam: str) -> str:
11 | """
12 | Avoid having a slash in the urlparam URL part,
13 | because it would not get URL-encoded.
14 | See https://stackoverflow.com/questions/67849991/django-urls-reverse-url-encoding-the-slash
15 | Possible replacement characters are
16 | codepoint char utf8 name oldname
17 | U+2044 ⁄ e2 81 84 FRACTION SLASH
18 | U+2215 ∕ e2 88 95 DIVISION SLASH
19 | U+FF0F / ef bc 8f FULLWIDTH SOLIDUS FULLWIDTH SLASH
20 | None of them will look quite right if the browser shows the char rather than
21 | the %-escape in the address line, but DIVISION SLASH comes close.
22 | The normal slash is
23 | U+002F / 2f SOLIDUS SLASH
24 | To get back the urlparam after calling escape_slash,
25 | the URL will be formed (via {% url ... } or reverse()) and URL-encoded,
26 | sent to the browser,
27 | received by Django in a request, URL-unencoded, split,
28 | its param parts handed to a view as args or kwargs,
29 | and finally unescape_slash will be called by the view.
30 | """
31 | return urlparam.replace('/', ALT_SLASH)
32 |
33 |
34 | def unescape_slash(urlparam_q: str) -> str:
35 | return urlparam_q.replace(ALT_SLASH, '/')
36 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/migrate.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/anwesende/users/views.py:
--------------------------------------------------------------------------------
1 | from django.contrib import messages
2 | from django.contrib.auth import get_user_model
3 | from django.contrib.auth.mixins import LoginRequiredMixin
4 | from django.urls import reverse
5 | from django.utils.translation import gettext_lazy as _
6 | from django.views.generic import DetailView, RedirectView, UpdateView
7 |
8 | User = get_user_model()
9 |
10 |
11 | class UserDetailView(LoginRequiredMixin, DetailView):
12 |
13 | model = User
14 | slug_field = "username"
15 | slug_url_kwarg = "username"
16 |
17 |
18 | user_detail_view = UserDetailView.as_view()
19 |
20 |
21 | class UserUpdateView(LoginRequiredMixin, UpdateView):
22 |
23 | model = User
24 | fields = ["name"]
25 |
26 | def get_success_url(self):
27 | return reverse("users:detail", kwargs={"username": self.request.user.username})
28 |
29 | def get_object(self):
30 | return User.objects.get(username=self.request.user.username)
31 |
32 | def form_valid(self, form):
33 | messages.add_message(
34 | self.request, messages.INFO, _("Infos successfully updated")
35 | )
36 | return super().form_valid(form)
37 |
38 |
39 | user_update_view = UserUpdateView.as_view()
40 |
41 |
42 | class UserRedirectView(LoginRequiredMixin, RedirectView):
43 |
44 | permanent = False
45 |
46 | def get_redirect_url(self):
47 | return reverse("users:detail", kwargs={"username": self.request.user.username})
48 |
49 |
50 | user_redirect_view = UserRedirectView.as_view()
51 |
--------------------------------------------------------------------------------
/anwesende/contrib/sites/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | import django.contrib.sites.models
2 | from django.contrib.sites.models import _simple_domain_name_validator
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = []
9 |
10 | operations = [
11 | migrations.CreateModel(
12 | name="Site",
13 | fields=[
14 | (
15 | "id",
16 | models.AutoField(
17 | verbose_name="ID",
18 | serialize=False,
19 | auto_created=True,
20 | primary_key=True,
21 | ),
22 | ),
23 | (
24 | "domain",
25 | models.CharField(
26 | max_length=100,
27 | verbose_name="domain name",
28 | validators=[_simple_domain_name_validator],
29 | ),
30 | ),
31 | ("name", models.CharField(max_length=50, verbose_name="display name")),
32 | ],
33 | options={
34 | "ordering": ("domain",),
35 | "db_table": "django_site",
36 | "verbose_name": "site",
37 | "verbose_name_plural": "sites",
38 | },
39 | bases=(models.Model,),
40 | managers=[("objects", django.contrib.sites.models.SiteManager())],
41 | )
42 | ]
43 |
--------------------------------------------------------------------------------
/anwesende/room/tests/test_migrations.py:
--------------------------------------------------------------------------------
1 | # see https://github.com/wemake-services/django-test-migrations
2 | import typing as tg
3 |
4 | import django_test_migrations.migrator as dtmm
5 | import pytest
6 |
7 |
8 | @pytest.mark.django_db
9 | def test_room_descriptor_DATA_migration(migrator: dtmm.Migrator):
10 | #--- create migration state before introducing Room.descriptor:
11 | old_state = migrator.apply_initial_migration(('room', '0010_visit_status_3g'))
12 |
13 | #--- create a Room:
14 | User = old_state.apps.get_model('users', 'User')
15 | user = User.objects.create(name="x") # needed for Importstep
16 | Importstep = old_state.apps.get_model('room', 'Importstep')
17 | importstep = Importstep.objects.create(user=user) # needed for Room
18 | Room = old_state.apps.get_model('room', 'Room')
19 | to_be_migrated = Room.objects.create(
20 | organization="myorg", department="mydep",
21 | building="mybldg", room="myroom",
22 | row_dist=1.3, seat_dist=0.8,
23 | seat_last="r2s3", importstep=importstep)
24 | assert getattr(to_be_migrated, 'descriptor', None) is None
25 |
26 | #--- migrate:
27 | new_state = migrator.apply_tested_migration([
28 | ('room', '0011_room_descriptor'),
29 | ('room', '0012_room_descriptor_DATA'), ])
30 |
31 | #--- assert descriptor is filled correctly:
32 | Room = new_state.apps.get_model('room', 'Room')
33 | newroom = Room.objects.get()
34 | assert newroom.descriptor == "myorg;mydep;mybldg;myroom"
35 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/runserver.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/runserver_plus.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/anwesende/room/migrations/0004_extend_importstep.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.10 on 2020-10-22 07:42
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12 | ('room', '0003_add_visit_cookie'),
13 | ]
14 |
15 | operations = [
16 | migrations.RemoveField(
17 | model_name='importstep',
18 | name='randomkey',
19 | ),
20 | migrations.AddField(
21 | model_name='importstep',
22 | name='num_existing_rooms',
23 | field=models.IntegerField(default=0),
24 | ),
25 | migrations.AddField(
26 | model_name='importstep',
27 | name='num_existing_seats',
28 | field=models.IntegerField(default=0),
29 | ),
30 | migrations.AddField(
31 | model_name='importstep',
32 | name='num_new_rooms',
33 | field=models.IntegerField(default=0),
34 | ),
35 | migrations.AddField(
36 | model_name='importstep',
37 | name='num_new_seats',
38 | field=models.IntegerField(default=0),
39 | ),
40 | migrations.AddField(
41 | model_name='importstep',
42 | name='user',
43 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
44 | preserve_default=False,
45 | ),
46 | ]
47 |
--------------------------------------------------------------------------------
/anwesende/room/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | import urllib.parse
2 |
3 | import django.urls as dju
4 | import pytest
5 |
6 | import anwesende.room.utils as aru
7 |
8 |
9 | def test_escape_slash():
10 | # We use URLs containing path elements containing non-ASCII characters and '/'.
11 | # The former are handled by Django, but '/' is left as is. See here:
12 | assert dju.reverse('room:visit', args=['a b']) == "/Sa%20b"
13 | assert dju.reverse('room:visit', args=['a%b']) == "/Sa%25b"
14 | assert dju.reverse('room:visit', args=['Ä']) == "/S%C3%84"
15 | assert dju.reverse('room:visit', args=['a%20b']) == "/Sa%2520b"
16 | with pytest.raises(dju.exceptions.NoReverseMatch):
17 | assert dju.reverse('room:visit', args=['a/b']) == "/Sa%2fb"
18 | # not found: It has two path elements, not just one
19 |
20 | # Our approach is to replace slashes in such URL parts with a slash-like
21 | # character and reverse the replacement before we use the value:
22 | kwargs = dict(organization="myorg", department="Zentrale Räume",
23 | building="A/vorne")
24 | # The following transformation would be done in a template in the call
25 | # of {% url ... %} by applying the same-named template filter.
26 | # See show_rooms.html for examples.
27 | kwargs = { key: aru.escape_slash(value)
28 | for key, value in kwargs.items() }
29 | url = dju.reverse('room:show-rooms-building', kwargs=kwargs) # does not fail!
30 | print(url)
31 | match = dju.resolve(urllib.parse.unquote(url))
32 | assert aru.unescape_slash(match.kwargs['organization']) == 'myorg'
33 | assert aru.unescape_slash(match.kwargs['building']) == 'A/vorne'
--------------------------------------------------------------------------------
/anwesende/templates/account/login.html:
--------------------------------------------------------------------------------
1 | {% extends "account/base.html" %}
2 |
3 | {% load i18n %}
4 | {% load account socialaccount %}
5 | {% load crispy_forms_tags %}
6 |
7 | {% block head_title %}{% trans "Sign In" %}{% endblock %}
8 |
9 | {% block inner %}
10 |
11 |
{% trans "Sign In" %}
12 |
13 | {% get_providers as socialaccount_providers %}
14 |
15 | {% if socialaccount_providers %}
16 |
{% blocktrans with site.name as site_name %}Please sign in with one
17 | of your existing third party accounts. Or, sign up
18 | for a {{ site_name }} account and sign in below:{% endblocktrans %}
19 |
20 |
21 |
22 |
23 | {% include "socialaccount/snippets/provider_list.html" with process="login" %}
24 |
8 | Beschreiben Sie die gewünschte Person mit dem Formular unten und
9 | geben Sie einen passenden Zeitraum an.
10 | Alle Kriterien müssen zugleich erfüllt sein, damit sie zutreffen.
11 |
12 |
13 | Prüfen Sie das Suchergebnis. Zu viele Treffer?
14 | Dann schärfere Kriterien benutzen.
15 | Am besten eignet sich normalerweise die Emailadresse.
16 |
17 |
18 | Wenn die Trefferliste passend aussieht, mit Knopf Nummer 2
19 | die Liste der Kontakte ansehen und auf Plausibilität prüfen.
20 | Wenn die auch in Ordnung ist, mit Knopf 3 die Liste als Excel herunterladen.
21 |
22 |
23 |
24 | Im Formular unten steht das Prozentzeichen % für Teile, die
25 | egal oder unbekannt sind.
26 |
47 | {% endblock content %}
48 |
--------------------------------------------------------------------------------
/config/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for anwesende project.
3 |
4 | This module contains the WSGI application used by Django's development server
5 | and any production WSGI deployments. It should expose a module-level variable
6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover
7 | this application via the ``WSGI_APPLICATION`` setting.
8 |
9 | Usually you will have the standard Django WSGI application here, but it also
10 | might make sense to replace the whole Django WSGI application with a custom one
11 | that later delegates to the Django one. For example, you could introduce WSGI
12 | middleware here, or combine a Django application with an application of another
13 | framework.
14 |
15 | """
16 | import os
17 | import sys
18 | from pathlib import Path
19 |
20 | from django.core.wsgi import get_wsgi_application
21 |
22 | # This allows easy placement of apps within the interior
23 | # anwesende directory.
24 | ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent
25 | sys.path.append(str(ROOT_DIR / "anwesende"))
26 | # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks
27 | # if running multiple sites in the same mod_wsgi process. To fix this, use
28 | # mod_wsgi daemon mode with each site in its own daemon process, or use
29 | # os.environ["DJANGO_SETTINGS_MODULE"] = "config.settings.production"
30 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
31 |
32 | # This application object is used by any WSGI server configured to use this
33 | # file. This includes Django's development server, if the WSGI_APPLICATION
34 | # setting points here.
35 | application = get_wsgi_application()
36 | # Apply WSGI middleware here.
37 | # from helloworld.wsgi import HelloWorldApplication
38 | # application = HelloWorldApplication(application)
39 |
--------------------------------------------------------------------------------
/config/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.conf.urls.static import static
3 | from django.contrib import admin
4 | from django.urls import include, path
5 | from django.views import defaults as default_views
6 | from django.views.generic import TemplateView
7 |
8 | urlpatterns = [
9 | path(
10 | "about/", TemplateView.as_view(template_name="pages/about.html"), name="about"
11 | ),
12 | # Django Admin, use {% url 'admin:index' %}
13 | path(settings.ADMIN_URL, admin.site.urls),
14 | # User management
15 | path("users/", include("anwesende.users.urls", namespace="users")),
16 | path("accounts/", include("allauth.urls")),
17 | # Your stuff: custom urls includes go here
18 | path("", include("anwesende.room.urls", namespace="room")),
19 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
20 |
21 |
22 | if settings.DEBUG:
23 | # This allows the error pages to be debugged during development, just visit
24 | # these url in browser to see how these error pages look like.
25 | urlpatterns += [
26 | path(
27 | "400/",
28 | default_views.bad_request,
29 | kwargs={"exception": Exception("Bad Request!")},
30 | ),
31 | path(
32 | "403/",
33 | default_views.permission_denied,
34 | kwargs={"exception": Exception("Permission Denied")},
35 | ),
36 | path(
37 | "404/",
38 | default_views.page_not_found,
39 | kwargs={"exception": Exception("Page not Found")},
40 | ),
41 | path("500/", default_views.server_error),
42 | ]
43 | if "debug_toolbar" in settings.INSTALLED_APPS:
44 | import debug_toolbar
45 |
46 | urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns
47 |
--------------------------------------------------------------------------------
/anwesende/room/management/commands/delete_outdated_data.py:
--------------------------------------------------------------------------------
1 | import datetime as dt
2 | import logging
3 |
4 | import django.core.management.base as djcmb
5 | import django.utils.timezone as djut
6 | from django.conf import settings
7 |
8 | import anwesende.room.models as arm
9 | import anwesende.utils.date as aud
10 |
11 |
12 | class Command(djcmb.BaseCommand):
13 | help = "Deletes all Visits older than settings.DATA_RETENTION_DAYS."
14 |
15 | def handle(self, *args, **options):
16 | #--- deleted data older than retention time:
17 | horizon = djut.localtime() - dt.timedelta(days=settings.DATA_RETENTION_DAYS)
18 | oldvisits = arm.Visit.objects.filter(submission_dt__lt=horizon)
19 | howmany_deleted = oldvisits.count()
20 | howmany_exist = arm.Visit.objects.count()
21 | msg = "delete_outdated_data: deleting %d visit entries before %s (of %d existing)" % \
22 | (howmany_deleted, aud.dtstring(horizon), howmany_exist)
23 | logging.info(msg)
24 | oldvisits.delete()
25 | #--- deleted status_3g field in data older than status_3g retention time:
26 | if not settings.USE_STATUS_3G_FIELD or \
27 | settings.DATA_RETENTION_DAYS_STATUS_3G >= settings.DATA_RETENTION_DAYS:
28 | return # nothing else to do
29 | horizon_3g = djut.localtime() - dt.timedelta(days=settings.DATA_RETENTION_DAYS_STATUS_3G)
30 | youngvisits = arm.Visit.objects.filter(submission_dt__lt=horizon_3g)
31 | howmany_cleaned = youngvisits.count()
32 | howmany_exist = arm.Visit.objects.count()
33 | msg = "delete_outdated_data: cleansing %d status_3g values before %s (of %d existing)" % \
34 | (howmany_cleaned, aud.dtstring(horizon_3g, time=True), howmany_exist)
35 | logging.info(msg)
36 | youngvisits.update(status_3g=arm.G_UNKNOWN)
37 |
--------------------------------------------------------------------------------
/anwesende/utils/date.py:
--------------------------------------------------------------------------------
1 | import datetime as dt
2 | import re
3 | import typing as tg
4 |
5 | import arrow
6 | import django.utils.timezone as djut
7 |
8 |
9 | def dtstring(dtobj, date=True, time=False) -> str:
10 | local_dt = djut.localtime(dtobj)
11 | if date and time:
12 | format = "%Y-%m-%d %H:%M"
13 | elif date:
14 | format = "%Y-%m-%d"
15 | elif time:
16 | format = "%H:%M"
17 | else:
18 | assert False, "You don't want an empty nowstring, do you?"
19 | return local_dt.strftime(format)
20 |
21 |
22 | def nowstring(date=True, time=False) -> str:
23 | now = djut.localtime()
24 | return dtstring(now, date, time)
25 |
26 |
27 | def make_dt(dto: tg.Union[dt.datetime, str], timestr: str = None) -> dt.datetime:
28 | """Return a datetime with dto date (today if "now") and timestr hour/minute."""
29 | # 1. Must never use dt.datetime(..., tzinfo=...) with pytz,
30 | # because it will often end up with a historically outdated timezone.
31 | # see http://pytz.sourceforge.net/
32 | # 2. We must not rely on a naive datetime_obj.day etc. because it may be off
33 | # wrt the server's TIME_ZONE, which we use for interpreting timestr.
34 | # 3. Django uses UTC timezone on djut.now()! Use djut.localtime().
35 | if dto == 'now':
36 | dto = djut.localtime()
37 | assert isinstance(dto, dt.date)
38 | assert dto.tzinfo is not None # reject naive input: we need a tz
39 | if timestr:
40 | mm = re.match(r"^(\d\d):(\d\d)$", timestr)
41 | assert mm, f"must use hh:mm timestr format: {timestr}"
42 | hour, minute = (int(mm.group(1)), int(mm.group(2)))
43 | else:
44 | hour, minute = (dto.hour, dto.minute)
45 | return arrow.Arrow(*(dto.year, dto.month, dto.day, hour, minute),
46 | tzinfo=djut.get_current_timezone()).datetime
47 |
--------------------------------------------------------------------------------
/compose/postgres/maintenance/restore:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 |
4 | ### Restore database from a backup.
5 | ###
6 | ### Parameters:
7 | ### <1> filename of an existing backup.
8 | ###
9 | ### Usage:
10 | ### $ docker-compose -f .yml (exec |run --rm) postgres restore <1>
11 |
12 |
13 | set -o errexit
14 | set -o pipefail
15 | set -o nounset
16 |
17 |
18 | working_dir="$(dirname ${0})"
19 | source "${working_dir}/_sourced/constants.sh"
20 | source "${working_dir}/_sourced/messages.sh"
21 |
22 |
23 | if [[ -z ${1+x} ]]; then
24 | message_error "Backup filename is not specified yet it is a required parameter. Make sure you provide one and try again."
25 | exit 1
26 | fi
27 | backup_filename="${BACKUP_DIR_PATH}/${1}"
28 | if [[ ! -f "${backup_filename}" ]]; then
29 | message_error "No backup with the specified filename found. Check out the 'backups' maintenance script output to see if there is one and try again."
30 | exit 1
31 | fi
32 |
33 | message_welcome "Restoring the '${POSTGRES_DB}' database from the '${backup_filename}' backup..."
34 |
35 | if [[ "${POSTGRES_USER}" == "postgres" ]]; then
36 | message_error "Restoring as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with another one and try again."
37 | exit 1
38 | fi
39 |
40 | export PGHOST="${POSTGRES_HOST}"
41 | export PGPORT="${POSTGRES_PORT}"
42 | export PGUSER="${POSTGRES_USER}"
43 | export PGPASSWORD="${POSTGRES_PASSWORD}"
44 | export PGDATABASE="${POSTGRES_DB}"
45 |
46 | message_info "Dropping the database..."
47 | dropdb "${PGDATABASE}"
48 |
49 | message_info "Creating a new database..."
50 | createdb --owner="${POSTGRES_USER}"
51 |
52 | message_info "Applying the backup to the new database..."
53 | gunzip -c "${backup_filename}" | psql "${POSTGRES_DB}"
54 |
55 | message_success "The '${POSTGRES_DB}' database has been restored from the '${backup_filename}' backup."
56 |
--------------------------------------------------------------------------------
/config/settings/test.py:
--------------------------------------------------------------------------------
1 | """
2 | With these settings, tests run faster.
3 | """
4 |
5 | from .base import * # noqa
6 | from .base import env
7 |
8 | # GENERAL
9 | # ------------------------------------------------------------------------------
10 | # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
11 | SECRET_KEY = env(
12 | "DJANGO_SECRET_KEY",
13 | default="si0IDXTT3xOYJKPUcvhRmbGMbjGcBXZdz9oWT8Y9xzSE1oQtHxPOzIxFHy4f5BjM",
14 | )
15 | # https://docs.djangoproject.com/en/dev/ref/settings/#test-runner
16 | TEST_RUNNER = "django.test.runner.DiscoverRunner"
17 |
18 | # CACHES
19 | # ------------------------------------------------------------------------------
20 | # https://docs.djangoproject.com/en/dev/ref/settings/#caches
21 | CACHES = {
22 | "default": {
23 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
24 | "LOCATION": "",
25 | }
26 | }
27 |
28 | # PASSWORDS
29 | # ------------------------------------------------------------------------------
30 | # https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers
31 | PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
32 |
33 | # TEMPLATES
34 | # ------------------------------------------------------------------------------
35 | TEMPLATES[-1]["OPTIONS"]["loaders"] = [ # type: ignore[index] # noqa F405
36 | (
37 | "django.template.loaders.cached.Loader",
38 | [
39 | "django.template.loaders.filesystem.Loader",
40 | "django.template.loaders.app_directories.Loader",
41 | ],
42 | )
43 | ]
44 |
45 | # EMAIL
46 | # ------------------------------------------------------------------------------
47 | # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
48 | EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
49 |
50 | # Your stuff...
51 | # ------------------------------------------------------------------------------
52 |
--------------------------------------------------------------------------------
/anwesende/templates/room/searchbyroom.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% load crispy_forms_tags %}
3 |
4 | {% block content %}
5 |
Suche nach Besuchen in Räumen
6 |
7 |
8 | Beschreiben Sie den gewünschten Raum mit der Suchzeile unten und
9 | geben Sie einen passenden Zeitraum an.
10 |
11 |
12 | Prüfen Sie das Suchergebnis. Mehr als ein Treffer?
13 | Dann schärfere Kriterien benutzen.
14 |
15 |
16 | Wenn die Trefferliste passend aussieht, mit Knopf Nummer 2
17 | die Liste der Kontakte ansehen und auf Plausibilität prüfen.
18 | Wenn die auch in Ordnung ist, mit Knopf 3 die Liste als Excel herunterladen.
19 |
7 | Zeigt für eine Teilmenge der Räume (gemäß Suchbegriff),
8 | wie viele verschiedene Personen pro Woche anwwesend waren
9 | und auf wie viele Bereiche sich das verteilt hat.
10 |
11 |
12 | Die Zahl der Wochen ergibt sich aus dem gesetzlich vorgeschriebenen
13 | Zeithorizont der Datenbank von
14 | {{ settings.DATA_RETENTION_DAYS }} Tagen.
15 |
60 | {% endfor %}
61 | {% endblock content %}
62 |
--------------------------------------------------------------------------------
/anwesende/room/migrations/0005_number_to_seatnumber.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.10 on 2020-12-30 11:36
2 |
3 | import anwesende.utils.validators
4 | import django.core.validators
5 | from django.db import migrations, models
6 | import re
7 |
8 |
9 | class Migration(migrations.Migration):
10 |
11 | dependencies = [
12 | ('room', '0004_extend_importstep'),
13 | ]
14 |
15 | operations = [
16 | migrations.RenameField(
17 | model_name='seat',
18 | old_name='number',
19 | new_name='seatnumber',
20 | ),
21 | migrations.AlterField(
22 | model_name='visit',
23 | name='familyname',
24 | field=models.CharField(db_index=True, help_text='Wie im Ausweis angegeben / as shown in your passport (Latin script)', max_length=80, validators=[anwesende.utils.validators.validate_isprintable], verbose_name='Familienname / Family name'),
25 | ),
26 | migrations.AlterField(
27 | model_name='visit',
28 | name='givenname',
29 | field=models.CharField(db_index=True, help_text='Rufname / the firstname by which you are commonly known', max_length=80, validators=[anwesende.utils.validators.validate_isprintable], verbose_name='Vorname / Given name'),
30 | ),
31 | migrations.AlterField(
32 | model_name='visit',
33 | name='phone',
34 | field=models.CharField(db_index=True, help_text="Mit Ländervorwahl, z.B. +49 151... in Deutschland / With country code, starting with '+'", max_length=80, validators=[django.core.validators.RegexValidator(flags=re.RegexFlag['ASCII'], message='Falsches Format für eine Telefonnummer / Wrong format as a phone number', regex='^\\+\\d\\d[\\d /-]+$')], verbose_name='Mobilfunknummer / Mobile phone number'),
35 | ),
36 | migrations.AlterField(
37 | model_name='visit',
38 | name='street_and_number',
39 | field=models.CharField(db_index=True, help_text="Wohnadresse für diese Woche / This week's living address", max_length=80, validators=[anwesende.utils.validators.validate_isprintable], verbose_name='Straße und Hausnummer / Street and number'),
40 | ),
41 | migrations.AlterField(
42 | model_name='visit',
43 | name='town',
44 | field=models.CharField(db_index=True, max_length=80, validators=[anwesende.utils.validators.validate_isprintable], verbose_name='Ort / Town'),
45 | ),
46 | migrations.AlterField(
47 | model_name='visit',
48 | name='zipcode',
49 | field=models.CharField(db_index=True, max_length=80, validators=[django.core.validators.RegexValidator(flags=re.RegexFlag['ASCII'], message='5 Ziffern bitte / 5 digits, please', regex='^\\d{5}$')], verbose_name='Postleitzahl / Postal code'),
50 | ),
51 | ]
52 |
--------------------------------------------------------------------------------
/config/settings/local.py:
--------------------------------------------------------------------------------
1 | from .base import * # noqa
2 | from .base import env
3 |
4 | # GENERAL
5 | # ------------------------------------------------------------------------------
6 | # https://docs.djangoproject.com/en/dev/ref/settings/#debug
7 | DEBUG = True
8 | # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
9 | SECRET_KEY = env(
10 | "DJANGO_SECRET_KEY",
11 | default="Q3xPLND0vJXF2XkVCdirTe6vs2Ejw3QkOSc34d77tHFpWfVPxk82ZeeIxltmMDvE",
12 | )
13 | # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
14 | ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1"]
15 |
16 | # CACHES
17 | # ------------------------------------------------------------------------------
18 | # https://docs.djangoproject.com/en/dev/ref/settings/#caches
19 | CACHES = {
20 | "default": {
21 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
22 | "LOCATION": "",
23 | }
24 | }
25 |
26 | # EMAIL
27 | # ------------------------------------------------------------------------------
28 | # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
29 | EMAIL_BACKEND = env(
30 | "DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend"
31 | )
32 |
33 | # WhiteNoise
34 | # ------------------------------------------------------------------------------
35 | # http://whitenoise.evans.io/en/latest/django.html#using-whitenoise-in-development
36 | INSTALLED_APPS = ["whitenoise.runserver_nostatic"] + INSTALLED_APPS # noqa F405
37 |
38 |
39 | # django-debug-toolbar
40 | # ------------------------------------------------------------------------------
41 | # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#prerequisites
42 | INSTALLED_APPS += [] #"debug_toolbar"] # noqa F405
43 | # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#middleware
44 | MIDDLEWARE += [] # "debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa F405
45 | # https://django-debug-toolbar.readthedocs.io/en/latest/configuration.html#debug-toolbar-config
46 | DEBUG_TOOLBAR_CONFIG = {
47 | "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"],
48 | "SHOW_TEMPLATE_CONTEXT": True,
49 | }
50 | # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#internal-ips
51 | INTERNAL_IPS = ["127.0.0.1", "10.0.2.2"]
52 | if env("USE_DOCKER") == "yes":
53 | import socket
54 |
55 | hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
56 | INTERNAL_IPS += [".".join(ip.split(".")[:-1] + ["1"]) for ip in ips]
57 |
58 | # django-extensions
59 | # ------------------------------------------------------------------------------
60 | # https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration
61 | INSTALLED_APPS += ["django_extensions"] # noqa F405
62 |
63 | # Your stuff...
64 | # ------------------------------------------------------------------------------
65 |
--------------------------------------------------------------------------------
/anwesende/utils/excel.py:
--------------------------------------------------------------------------------
1 | import collections
2 | import typing as tg
3 |
4 | import openpyxl
5 |
6 | Columnsdict = tg.Mapping[str, tg.List]
7 |
8 |
9 | def read_excel_as_columnsdict(filename: str) -> Columnsdict:
10 | """
11 | Return raw data from Excel's active sheet:
12 | strings will be stripped of leading/trailing whitespace;
13 | everything else will be converted to string.
14 | First row is treated as column headers; column order is kept.
15 | """
16 | workbook = openpyxl.load_workbook(filename)
17 | sheet = workbook.active
18 | result = collections.OrderedDict()
19 | for col in sheet.iter_cols(values_only=True):
20 | colname = col[0]
21 | assert isinstance(colname, str), f"type(colname) = {type(colname)}"
22 | result[colname] = [_cleansed(cell) for cell in col[1:]]
23 | return result
24 |
25 |
26 | def _cleansed(cell):
27 | if isinstance(cell, str):
28 | return cell.strip()
29 | else:
30 | return str(cell or "") # None or int or what-have-you
31 |
32 |
33 | RowsListsType = tg.Mapping[str, tg.List[tg.Optional[tg.NamedTuple]]] # sheetname -> sheetcontents
34 |
35 |
36 | def write_excel_from_rowslists(filename: str, rowslists: RowsListsType,
37 | indexcolumn=False) -> None:
38 | workbook = openpyxl.Workbook()
39 | for sheetname, rows in rowslists.items():
40 | sheet = workbook.create_sheet(sheetname)
41 | indexdigits = len(str(len(rows))) if indexcolumn else 0
42 | if len(rows) > 0:
43 | _write_column_headings(sheet, rows[0], 1, indexdigits)
44 | for rownum, row in enumerate(rows, start=2):
45 | _write_row(sheet, row, rownum, indexdigits)
46 | del workbook["Sheet"] # get rid of initial default sheet
47 | workbook.save(filename)
48 |
49 |
50 | def _write_column_headings(sheet, tupl: tg.Optional[tg.NamedTuple],
51 | rownum: int, indexdigits: int):
52 | # use the tuple's element names as headings
53 | assert tupl # None does not occur here
54 | font = openpyxl.styles.Font(bold=True)
55 | if indexdigits:
56 | sheet.cell(column=1, row=1, value="index")
57 | sheet.cell(column=1, row=1).font = font
58 | for colnum, colname in enumerate(tupl._fields, start=1 + (indexdigits != 0)):
59 | sheet.cell(column=colnum, row=rownum, value=colname)
60 | sheet.cell(column=colnum, row=rownum).font = font
61 |
62 |
63 | def _write_row(sheet, tupl: tg.Optional[tg.NamedTuple],
64 | rownum: int, indexdigits: tg.Optional[int]):
65 | if indexdigits:
66 | index = str(rownum - 1).zfill(indexdigits)
67 | sheet.cell(column=1, row=rownum, value=index)
68 | if tupl is None:
69 | return # nothing else to do
70 | for colnum, value in enumerate(tupl, start=1 + (indexdigits != 0)):
71 | sheet.cell(column=colnum, row=rownum, value=value)
72 |
--------------------------------------------------------------------------------
/anwesende/templates/account/email.html:
--------------------------------------------------------------------------------
1 |
2 | {% extends "account/base.html" %}
3 |
4 | {% load i18n %}
5 | {% load crispy_forms_tags %}
6 |
7 | {% block head_title %}{% trans "Account" %}{% endblock %}
8 |
9 | {% block inner %}
10 |
{% trans "E-mail Addresses" %}
11 |
12 | {% if user.emailaddress_set.all %}
13 |
{% trans 'The following e-mail addresses are associated with your account:' %}
14 |
15 |
44 |
45 | {% else %}
46 |
{% trans 'Warning:'%} {% trans "You currently do not have any e-mail address set up. You should really add an e-mail address so you can receive notifications, reset your password, etc." %}
47 |
48 | {% endif %}
49 |
50 |
51 |
{% trans "Add E-mail Address" %}
52 |
53 |
58 |
59 | {% endblock %}
60 |
61 |
62 | {% block javascript %}
63 | {{ block.super }}
64 |
79 | {% endblock %}
80 |
81 |
--------------------------------------------------------------------------------
/anwesende/room/reports.py:
--------------------------------------------------------------------------------
1 | import dataclasses
2 | import datetime as dt
3 | import typing as tg
4 |
5 | from django.conf import settings
6 | import django.db.models as djdm
7 | from django.db.models import Count
8 | import django.utils.timezone as djut
9 |
10 | import anwesende.room.models as arm
11 | import anwesende.utils.lookup # noqa, registers lookup
12 |
13 |
14 | @dataclasses.dataclass
15 | class Weekreport:
16 | week_from: dt.datetime
17 | week_to: dt.datetime
18 | organizationsN: int
19 | departmentsN: int
20 | buildingsN: int
21 | roomsN: int
22 | visitsN: int
23 | visitorsN: int
24 | visits_per_visitor: float
25 |
26 |
27 | def visits_by_department_report() -> tg.List[tg.Mapping[str, str]]:
28 | return list(arm.Room.objects.order_by('organization', 'department')
29 | .values('organization', 'department')
30 | .annotate(rooms=Count("id", distinct=True))
31 | .annotate(seats=Count("seat", distinct=True))
32 | .annotate(visits=Count("seat__visit", distinct=True))
33 | )
34 |
35 |
36 | def visitors_by_week_report(roomdescriptor: str) -> tg.List[Weekreport]:
37 | visit_qs = arm.Visit.objects.filter(seat__room__descriptor__ilike=roomdescriptor)
38 | weeksN = int(settings.DATA_RETENTION_DAYS / 7)
39 | oneweek = dt.timedelta(days=7)
40 | now = djut.localtime()
41 | result = []
42 | for weekI in range(weeksN):
43 | starttime = now - oneweek * (weeksN - weekI)
44 | endtime = starttime + oneweek
45 | result.append(weekreport_row(visit_qs, starttime, endtime))
46 | return result
47 |
48 |
49 | def weekreport_row(base_qs: djdm.QuerySet,
50 | starttime: dt.datetime, endtime: dt.datetime) -> Weekreport:
51 | myvisits_qs = base_qs.filter(present_from_dt__gte=starttime,
52 | present_from_dt__lte=endtime)
53 | def count(attr: str) -> int:
54 | return _get_count(myvisits_qs, attr)
55 | wr = Weekreport(
56 | week_from=starttime, week_to=endtime,
57 | organizationsN=count('organization'), departmentsN=count('department'),
58 | buildingsN=count('building'), roomsN=count('room'),
59 | visitsN=myvisits_qs.count(), visitorsN=_get_peoplecount(myvisits_qs),
60 | visits_per_visitor=None
61 | )
62 | wr.visits_per_visitor = wr.visitsN / wr.visitorsN if wr.visitorsN > 0 else 0.0
63 | return wr
64 |
65 |
66 |
67 | def _get_count(qs: djdm.QuerySet, attr) -> int:
68 | attrs = ['organization', 'department', 'building', 'room']
69 | selectors = ['seat__room__organization', 'seat__room__department',
70 | 'seat__room__building', 'seat__room__room']
71 | pos = attrs.index(attr)
72 | count = qs.order_by(*selectors[:pos+1]).distinct(*selectors[:pos+1]).count()
73 | # print("get_count", attr, selectors[:pos+1])
74 | return count
75 |
76 |
77 | def _get_peoplecount(qs: djdm.QuerySet) -> int:
78 | field = 'email' if settings.USE_EMAIL_FIELD else 'phone'
79 | return qs.order_by(field).distinct(field).count()
--------------------------------------------------------------------------------
/anwesende/room/tests/test_import.py:
--------------------------------------------------------------------------------
1 | import datetime as dt
2 | import re
3 | import typing as tg
4 |
5 | import pytest
6 |
7 | import anwesende.room.excel as are
8 | import anwesende.room.models as arm
9 | import anwesende.room.tests.test_excel as artte
10 | import anwesende.users.models as aum
11 |
12 | # #### scaffolding:
13 |
14 | excel_rooms1_filename = "anwesende/room/tests/data/rooms1.xlsx"
15 | excel_rooms2_filename = "anwesende/room/tests/data/rooms2.xlsx"
16 |
17 | @pytest.mark.django_db
18 | def test_displayable_importsteps():
19 | user = aum.User.objects.create(username="user1")
20 | # ----- create importstep1:
21 | stuff1 = are.create_seats_from_excel(excel_rooms1_filename, user)
22 | print(stuff1)
23 | # ----- check importstep1:
24 | assert arm.Importstep.objects.all().count() == 1
25 | importstep1 = arm.Importstep.objects.first()
26 | assert importstep1
27 | assert importstep1.num_existing_rooms == 0
28 | assert importstep1.num_existing_seats == 0
29 | assert importstep1.num_new_rooms == 2
30 | assert importstep1.num_new_seats == 20
31 | # ----- check displayable importstep1:
32 | steps1 = arm.Importstep.displayable_importsteps(dt.timedelta(days=1))
33 | assert len(steps1) == 1
34 | step1 = steps1[0]
35 | assert step1.num_existing_rooms == 0
36 | assert step1.num_existing_seats == 0
37 | assert step1.num_new_rooms == 2
38 | assert step1.num_new_seats == 20
39 | assert step1.num_qrcodes == 20 # type: ignore[attr-defined]
40 | assert step1.num_qrcodes_moved == 0 # type: ignore[attr-defined]
41 | # ----- create importstep2:
42 | stuff2 = are.create_seats_from_excel(excel_rooms2_filename, user)
43 | print(stuff2)
44 | # ----- check importstep2:
45 | assert arm.Importstep.objects.all().count() == 2
46 | importstep2 = arm.Importstep.objects.order_by('when').last()
47 | assert importstep2
48 | assert importstep2.num_existing_rooms == 1
49 | assert importstep2.num_existing_seats == 6
50 | assert importstep2.num_new_rooms == 0
51 | seats = arm.Seat.objects.filter(room__importstep=importstep2)
52 | for i, seat in enumerate(seats):
53 | print(i, seat)
54 | assert importstep2.num_new_seats == 2
55 | updated_seat = arm.Seat.objects.get(room__room='K40', seatnumber=1, rownumber=1)
56 | assert abs(updated_seat.room.row_dist - 1.2) < 0.0001 # no longer 1.1
57 | # ----- check displayable importstep1 again:
58 | steps2 = arm.Importstep.displayable_importsteps(dt.timedelta(days=1))
59 | assert len(steps2) == 2
60 | step1b = steps2[0] # order is oldest first
61 | assert step1b.num_new_seats == 20
62 | # first room is untouched, second room was updated:
63 | assert step1b.num_qrcodes == 14 # type: ignore[attr-defined]
64 | assert step1b.num_qrcodes_moved == 6 # type: ignore[attr-defined]
65 | # ----- check displayable importstep2:
66 | step2 = steps2[1]
67 | assert step2.num_existing_rooms == 1
68 | assert step2.num_existing_seats == 6
69 | assert step2.num_new_rooms == 0
70 | assert step2.num_new_seats == 2
71 | assert step2.num_qrcodes == 8 # type: ignore[attr-defined]
72 | assert step2.num_qrcodes_moved == 0 # type: ignore[attr-defined]
--------------------------------------------------------------------------------
/anwesende/room/tests/test_reports.py:
--------------------------------------------------------------------------------
1 | import datetime as dt
2 | from pprint import pprint
3 |
4 | import pytest
5 |
6 | import anwesende.room.models as arm
7 | import anwesende.room.reports as arr
8 | import anwesende.room.tests.makedata as artmd
9 |
10 | @pytest.mark.django_db
11 | def test_visits_by_department_report():
12 | descr = dict(org1=
13 | dict(dep1=
14 | dict(room1=(1,1),
15 | room2=(2,2)),
16 | dep2=
17 | dict(room3=(3,4))),
18 | org2=
19 | dict(dep3=
20 | dict(room4=(4,9),
21 | room5=(5,16))))
22 | artmd.make_organizations(descr)
23 | assert arm.Room.objects.count() == 5
24 | assert arm.Seat.objects.count() == 15
25 | assert arm.Visit.objects.count() == 32
26 | result = list(arr.visits_by_department_report())
27 | pprint(result)
28 | should = [
29 | {'organization': 'org1', 'department': 'dep1',
30 | 'rooms': 2, 'seats': 3, 'visits': 3},
31 | {'organization': 'org1', 'department': 'dep2',
32 | 'rooms': 1, 'seats': 3, 'visits': 4},
33 | {'organization': 'org2', 'department': 'dep3',
34 | 'rooms': 2, 'seats': 9, 'visits': 25} ]
35 | assert result == should
36 |
37 |
38 | @pytest.mark.django_db
39 | def test_visitors_by_week_report(freezer):
40 | #----- first week: create rooms and some visits:
41 | freezer.move_to("2021-12-03T02:03")
42 | seat_r1, = artmd.make_seats("room1", 1, "org1", "dep1")
43 | seat_r2, = artmd.make_seats("room2", 1, "org1", "dep1")
44 | seat_r2b, = artmd.make_seats("room2", 1, "org2", "dep2")
45 | artmd.make_visit(seat_r1, "p1")
46 | artmd.make_visit(seat_r2, "p1")
47 | artmd.make_visit(seat_r2, "p2")
48 | artmd.make_visit(seat_r2b, "p1")
49 | artmd.make_visit(seat_r2b, "p3")
50 | artmd.make_visit(seat_r2b, "p4")
51 | artmd.make_visit(seat_r2b, "p5")
52 |
53 | #----- second week: create more visits:
54 | freezer.move_to("2021-12-10T02:10")
55 | artmd.make_visit(seat_r2, "p1")
56 | artmd.make_visit(seat_r2, "p1") # double registration
57 | artmd.make_visit(seat_r2, "p2")
58 |
59 | #----- that evening, look at report:
60 | freezer.move_to("2021-12-10T18:00")
61 | #-- first week:
62 | wr = arr.visitors_by_week_report("%")
63 | assert wr[0].organizationsN == 2
64 | assert wr[0].departmentsN == 2
65 | assert wr[0].buildingsN == 2 # all same name
66 | assert wr[0].roomsN == 3 # only 2 different names
67 | assert wr[0].visitorsN == 5
68 | assert wr[0].visitsN == 7
69 | assert wr[0].visits_per_visitor == 7/5
70 |
71 | #-- second week:
72 | assert wr[0].organizationsN == 2
73 | assert wr[0].departmentsN == 2
74 | assert wr[0].buildingsN == 2
75 | assert wr[0].roomsN == 3 #
76 | assert wr[0].visitorsN == 5
77 | assert wr[0].visitsN == 7
78 | assert wr[0].visits_per_visitor == 7/5
79 |
80 | #-- first week, narrowed search:
81 | wr = arr.visitors_by_week_report("%dep1%")
82 | assert wr[0].organizationsN == 1
83 | assert wr[0].departmentsN == 1
84 | assert wr[0].buildingsN == 1
85 | assert wr[0].roomsN == 2
86 | assert wr[0].visitorsN == 2
87 | assert wr[0].visitsN == 3
88 | assert wr[0].visits_per_visitor == 3/2
89 |
90 |
91 |
--------------------------------------------------------------------------------
/config/envs/production-template.sh:
--------------------------------------------------------------------------------
1 | # to be bash-sourced
2 |
3 | # source .envs/otherenv.sh # uncomment and modify to derive from some base config
4 |
5 | # Overall configuration
6 | # ---------------------
7 | # One of the following values (case-sensitive):
8 | # LETSENCRYPT: use traefik webserver with Let's Encrypt for https
9 | # CERTS: use traefik webserver with manually created certificates
10 | # GUNICORN: expose the Gunicorn app server (for use behind existing web server)
11 | DEPLOYMODE=CERTS
12 | # 0 if build and execution are to happen on the _same_ server, 1 otherwise:
13 | REMOTE=1
14 | # shorter name for 'production', used for image names and path names:
15 | ENV_SHORTNAME=prod
16 | # Name by which the web server will be known to its users:
17 | SERVERNAME=anwesende.example.com
18 |
19 | # Settings required for DEPLOYMODE=LETSENCRYPT only
20 | # -------------------------------------------------
21 | # Email address how let's encrypt can reach the server admin:
22 | LETSENCRYPTEMAIL=anwesende-admin@myuniversity.de
23 |
24 | # Relationship between source machine and server
25 | # ----------------------------------------------
26 | # This whole block applies to REMOTE=1 only and values should be empty otherwise.
27 | # Target user at target host: an ssh-style user@machine:port string
28 | # needed to copy a few files and for remote script execution
29 | TUTH=prechelt@anwesende.imp.fu-berlin.de
30 | # Docker registry name and user (for login) and prefix string (for tag names).
31 | # Docker images are built locally then transfered to server via the registry.
32 | REGISTRY=git.imp.fu-berlin.de:5000
33 | REGISTRYUSER=prechelt
34 | REGISTRYPREFIX=git.imp.fu-berlin.de:5000/anwesende/anwesende
35 |
36 | # Port numbers
37 | # ------------
38 | # Required for deploymode GUNICORN only: what port to expose it on
39 | GUNICORN_PORT=5005
40 | # Required for deploymodes LETSENCRYPT and CERTS only:
41 | TRAEFIK_HTTP_PORT=80
42 | TRAEFIK_HTTPS_PORT=443
43 |
44 | # Django container
45 | # ------------------------------------------------------------------------------
46 | # Which user ID and group ID the Django process should have,
47 | # e.g. those of the installing user's login (obtain them by command 'id')
48 | DJANGO_UID=118205
49 | DJANGO_GID=10005
50 |
51 | # Docker environment modifications
52 | # --------------------------------
53 | # Variables called PATCH_XYZ will overwrite variable XYZ defined in the
54 | # global docker environment file derived from myenv.env when myenv.env
55 | # is processed into autogenerated.env.
56 | # Multiword values must be quoted here and will arrive there without quotes.
57 | PATCH_DJANGO_HTTPS_INSIST=False
58 |
59 |
60 | # Docker volumes
61 | # ------------------------------------------------------------------------------
62 | # Which directories on the server the docker containers created by
63 | # the docker-compose file will use to store their data.
64 | : ${VOLUMEDIR_PREFIX:=/srv/anwesende/$ENV_SHORTNAME}
65 | # Django logging files directory:
66 | VOLUME_SERVERDIR_DJANGO_LOG=$VOLUMEDIR_PREFIX/djangolog
67 | # The database as such:
68 | VOLUME_SERVERDIR_POSTGRES_DATA=$VOLUMEDIR_PREFIX/postgres_data
69 | # Plain-SQL backups of the database:
70 | VOLUME_SERVERDIR_POSTGRES_BACKUP=$VOLUMEDIR_PREFIX/postgres_backup
71 | # Certificate/key store (for DEPLOYMODE=LETSENCRYPT only):
72 | VOLUME_SERVERDIR_TRAEFIK_ACME=$VOLUMEDIR_PREFIX/traefik_acme
73 | # Certificate/key store (for DEPLOYMODE=CERTS only):
74 | VOLUME_SERVERDIR_TRAEFIK_SSL=$VOLUMEDIR_PREFIX/traefik_ssl
75 |
--------------------------------------------------------------------------------
/anwesende/templates/room/show_rooms.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% load anwesende_tags %}
3 |
4 | {% block content %}
5 |
27 | DE:
28 | Eine Open-Source-Webanwendung zur Anwesenheitserfassung.
29 | Dient im Infektionsfall zur Kontaktverfolgung in Zeiten der Pandemie:
30 |
31 |
32 | Teilnehmende Hochschulen
33 | registrieren ihre Räume*,
34 | erhalten für jeden
35 | Sitzplatz einen QR-Code
36 | (Beispiel*)
37 | und kleben diesen auf.
38 |
39 |
40 | Besucher von Räumen scannen ihren QR-Code
41 | (Beispiel*),
42 | füllen ein Formular mit Kontaktdaten aus
43 | (Beispiel*)
44 | und geben dabei die Anwesenheitszeit an.
45 |
46 | Diese Daten werden zentral gespeichert, geordnet
47 | nach Räumen und Zeiten.
48 |
49 |
50 | Im Infektionsfall meldet sich der/die Infizierte bei der Hochschule.
51 |
52 | Die Hochschule ruft für die fraglichen Tage die Daten dieser Person ab
53 | (über dieses Formular*)
54 | und erhält für jeden besuchten Raum die ganze Kontaktgruppe von
55 | Personen, die überlappend im gleichen Raum gewesen sind.
56 |
57 |
58 | Die Hochschule gibt diese Daten gemäß ihrer gesetzlichen Verpflichtung
59 | an das Gesundheitsamt weiter.
60 |
61 |
62 |
63 | *Diese Beispiellinks gehören zu einem Demonstrationsmodus.
64 | Sie erzwingen an Stellen, die normalerweise nur
65 | für die Datenverwalter_innen zugänglich sind, einen Fantasiesitzplatz
66 | in der Fantasieorganisation '{{ settings.DUMMY_ORG }}'.
67 | Die Daten, die man so ins Anwesenheitsformular eingibt,
68 | sind öffentlich sichtbar und lassen sich über das zugehörige
69 | Suchformular auch öffentlich abrufen.
70 | Das wirkliche Anwesenheitsformular erreicht man über das Scannen
71 | eines QR-Codes, der an einem echten Sitzplatz aufgeklebt ist.
72 | Das wirkliche Suchformular erreichen nur Datenverwalter_innen mit Passwort.
73 |
74 |
75 |
76 | EN:
77 | An open source web application for people to register their presence.
78 | Meant to help tracing contacts when someone got infected in pandemic times.
79 | This contact tracing is required by law.
80 |
81 |
82 | To use it as a visitor, scan the QR code posted at your seat and fill
83 | in the form shown. Details are explained there.
84 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/anwesende/room/migrations/0003_add_visit_cookie.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.10 on 2020-10-15 15:53
2 |
3 | import django.core.validators
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ('room', '0002_visit'),
11 | ]
12 |
13 | operations = [
14 | migrations.AddField(
15 | model_name='visit',
16 | name='cookie',
17 | field=models.TextField(default='abc', max_length=15, verbose_name='random string, used as pseudo-id'),
18 | preserve_default=False,
19 | ),
20 | migrations.AlterField(
21 | model_name='room',
22 | name='building',
23 | field=models.CharField(db_index=True, max_length=80),
24 | ),
25 | migrations.AlterField(
26 | model_name='room',
27 | name='department',
28 | field=models.CharField(db_index=True, max_length=80),
29 | ),
30 | migrations.AlterField(
31 | model_name='room',
32 | name='organization',
33 | field=models.CharField(db_index=True, max_length=80),
34 | ),
35 | migrations.AlterField(
36 | model_name='room',
37 | name='room',
38 | field=models.CharField(db_index=True, max_length=80),
39 | ),
40 | migrations.AlterField(
41 | model_name='visit',
42 | name='email',
43 | field=models.EmailField(db_index=True, help_text='Bitte immer die gleiche benutzen! / Please use the same one each time', max_length=80, verbose_name='Emailadresse / Email address'),
44 | ),
45 | migrations.AlterField(
46 | model_name='visit',
47 | name='familyname',
48 | field=models.CharField(db_index=True, help_text='Wie im Ausweis angegeben / as shown in your passport', max_length=80, verbose_name='Familienname / Family name'),
49 | ),
50 | migrations.AlterField(
51 | model_name='visit',
52 | name='givenname',
53 | field=models.CharField(db_index=True, help_text='Rufname / the firstname by which you are commonly known', max_length=80, verbose_name='Vorname / Given name'),
54 | ),
55 | migrations.AlterField(
56 | model_name='visit',
57 | name='phone',
58 | field=models.CharField(db_index=True, help_text="Mit Ländervorwahl, z.B. +49 151... in Deutschland / With country code, starting with '+'", max_length=80, validators=[django.core.validators.RegexValidator(message='Falsches Format für eine Telefonnummer / Wrong format as a phone number', regex='^\\+\\d\\d[\\d /-]+$')], verbose_name='Mobilfunknummer / Mobile phone number'),
59 | ),
60 | migrations.AlterField(
61 | model_name='visit',
62 | name='present_from_dt',
63 | field=models.DateTimeField(db_index=True, max_length=80),
64 | ),
65 | migrations.AlterField(
66 | model_name='visit',
67 | name='present_to_dt',
68 | field=models.DateTimeField(db_index=True, max_length=80),
69 | ),
70 | migrations.AlterField(
71 | model_name='visit',
72 | name='street_and_number',
73 | field=models.CharField(db_index=True, help_text="Wohnadresse für diese Woche / This week's living address", max_length=80, verbose_name='Straße und Hausnummer / Street and number'),
74 | ),
75 | migrations.AlterField(
76 | model_name='visit',
77 | name='town',
78 | field=models.CharField(db_index=True, max_length=80, verbose_name='Ort / Town'),
79 | ),
80 | migrations.AlterField(
81 | model_name='visit',
82 | name='zipcode',
83 | field=models.CharField(db_index=True, max_length=80, validators=[django.core.validators.RegexValidator(message='5 Ziffern bitte / 5 digits, please', regex='^\\d{5}$')], verbose_name='Postleitzahl / Postal code'),
84 | ),
85 | ]
86 |
--------------------------------------------------------------------------------
/anwesende/room/tests/test_forms.py:
--------------------------------------------------------------------------------
1 | import datetime as dt
2 | import typing as tg
3 |
4 | import django.core.exceptions as djce
5 | import django.utils.timezone as djut
6 | import pytest
7 |
8 | import anwesende.room.forms as arf
9 | import anwesende.room.models as arm
10 | import anwesende.room.tests.makedata as artmd
11 | import anwesende.utils.date as aud
12 |
13 |
14 | def test_TimeOnlyDateTimeField_to_python_OK():
15 | fmt = "%Y-%m-%d %H:%M"
16 | now = djut.localtime().strftime(fmt)
17 | for input in ("17:27", "1727"):
18 | dt_val = arf.TimeOnlyDateTimeField().to_python(input)
19 | dt_str = dt_val.strftime(fmt)
20 | print(f"{input} --> {dt_str}")
21 | assert dt_str[-5:] == "17:27" # correct time
22 | assert dt_str[:10] == now[:10] # today's date
23 |
24 |
25 | def test_TimeOnlyDateTimeField_to_python_wrong():
26 | with pytest.raises(djce.ValidationError) as ex:
27 | dt_val = arf.TimeOnlyDateTimeField().to_python("37:27") # noqa
28 | assert "Falsches Uhrzeitformat" in str(ex.value)
29 |
30 |
31 | def test_TimeRangeField_to_python_OK():
32 | now = djut.localtime()
33 | recently = now - dt.timedelta(hours=2) # range is 2hrs ago until now
34 | minute = dt.timedelta(minutes=1)
35 | timerange_str = "%s %s-%s" % (
36 | now.strftime('%Y-%m-%d'),
37 | recently.strftime('%H:%M'), now.strftime('%H:%M'))
38 | dt_from, dt_to = arf.TimeRangeField().to_python(timerange_str)
39 | assert recently-minute < dt_from < recently+minute
40 | assert now-minute < dt_to < now+minute
41 |
42 |
43 | def test_TimeRangeField_to_python_wrong():
44 | range = lambda rangestr: arf.TimeRangeField().to_python(rangestr)
45 | with pytest.raises(djce.ValidationError) as ex:
46 | range("some stuff")
47 | assert "Falsches Zeitraumformat" in str(ex.value)
48 | with pytest.raises(djce.ValidationError) as ex:
49 | range("2021-11-31 01:00-02:00") # 31st does not exist
50 | assert "day is out of range" in str(ex.value)
51 | with pytest.raises(djce.ValidationError) as ex:
52 | range("2021-11-13 02:00-03:60") # minute 60 does not exist
53 | assert "Falsches Zeitraumformat" in str(ex.value)
54 | with pytest.raises(djce.ValidationError) as ex:
55 | range("2021-11-13 03:00-03:00") # empty range
56 | assert "Anfang muss vor Ende liegen" in str(ex.value)
57 | with pytest.raises(djce.ValidationError) as ex:
58 | range("2021-11-13 04:00-03:00") # inverted range
59 | assert "Anfang muss vor Ende liegen" in str(ex.value)
60 |
61 |
62 | @pytest.mark.django_db
63 | def test_SearchByRoomForm():
64 | def tr(times: str) -> str:
65 | return f"{aud.nowstring()} {times}"
66 | def get_rooms(descr, times) -> tg.Set[arm.Room]:
67 | f = arf.SearchByRoomForm(dict(roomdescriptor=descr, timerange=tr(times)))
68 | f.full_clean()
69 | return set(f.cleaned_data['rooms_qs'])
70 | def get_visits(descr, times) -> tg.Set[arm.Visit]:
71 | f = arf.SearchByRoomForm(dict(roomdescriptor=descr, timerange=tr(times)))
72 | f.full_clean()
73 | return set(f.cleaned_data['visits_qs'])
74 | #----- create data:
75 | rm1s1, = artmd.make_seats("myroom", 1)
76 | rm2s1, = artmd.make_seats("otherroom", 1)
77 | rm1, rm2 = (rm1s1.room, rm2s1.room)
78 | v11 = artmd.make_visit(rm1s1, "p11", "02:00", "04:00") # noqa
79 | v12 = artmd.make_visit(rm1s1, "p12", "03:00", "05:00") # noqa
80 | v21 = artmd.make_visit(rm2s1, "p21", "02:00", "04:00") # noqa
81 | #----- check rooms_qs:
82 | assert get_rooms("%myroom", "02:00-04:00") == set((rm1,))
83 | assert get_rooms("%org%", "02:00-04:00") == set((rm1, rm2))
84 | assert get_rooms("---", "02:00-04:00") == set()
85 | #----- check visits_qs:
86 | assert get_visits("%myroom", "02:00-04:00") == set((v11, v12))
87 | assert get_visits("%myroom", "02:00-02:40") == set((v11,))
88 | assert get_visits("%myroom", "01:00-02:40") == set((v11,))
89 | assert get_visits("%myroom", "02:20-03:01") == set((v11,)) # too short for v12
90 | assert get_visits("%org%", "01:00-05:00") == set((v11, v12, v21))
91 | assert get_visits("---", "01:00-05:00") == set()
92 |
--------------------------------------------------------------------------------
/config/envs/myenv-template.env:
--------------------------------------------------------------------------------
1 | # anwesende per-installation configuration settings template.
2 | # See compose/** and config/settings/*.py for uses of the variables.
3 |
4 | # This file must contain only three kinds of line:
5 | # - empty line
6 | # - comment line starting with #
7 | # - variable definition line using 'NAME=value' format
8 | # No spaces are allowed around the '='.
9 |
10 | # Django
11 | # ------------------------------------------------------------------------------
12 | # Leave this as is or Django won't know itself:
13 | DJANGO_SETTINGS_MODULE=config.settings.production
14 | # Set this to True once your certificates and https are working nicely,
15 | # but not before (else the debugging of your https setup will be painful):
16 | DJANGO_HTTPS_INSIST=True
17 | # Set this to a secret random string (~40 bytes, alphanumeric)
18 | # or attackers can compromise requests:
19 | DJANGO_SECRET_KEY=
20 | # Comma-separated list of hostnames under which the server can be reached;
21 | # access by any other name will be ignored (should correspond to TRAEFIK_HOST):
22 | DJANGO_ALLOWED_HOSTS=name1.example.com,name2.example.com
23 |
24 | # Django Email
25 | # ------------------------------------------------------------------------------
26 | # Required for sending password-reset emails, nothing else.
27 | # Read about these on https://docs.djangoproject.com/en/dev/ref/settings/
28 | EMAIL_HOST=
29 | EMAIL_HOST_USER=
30 | EMAIL_HOST_PASSWORD=
31 | EMAIL_PORT=
32 | EMAIL_USE_SSL=True
33 | EMAIL_SUBJECT_PREFIX=[anwesende]
34 | DEFAULT_FROM_EMAIL=noreply@a.nwesen.de
35 |
36 | # Technical stuff: PostgreSQL, Gunicorn, Redis
37 | # ------------------------------------------------------------------------------
38 | # Leave Postgres HOST, PORT and DB as they are.
39 | # Pick random USER and PASSWORD:
40 | POSTGRES_HOST=postgres
41 | POSTGRES_PORT=5432
42 | POSTGRES_DB=anwesende
43 | POSTGRES_USER=
44 | POSTGRES_PASSWORD=
45 | # Defaults are probably OK, see https://docs.gunicorn.org/en/stable/settings.html#workers
46 | GUNICORN_WORKERS=5
47 | GUNICORN_THREADS=3
48 |
49 | # a.nwesen.de
50 | # ------------------------------------------------------------------------------
51 | # The random string stored in the cookie and shown in the output excel file
52 | # allows recognizing some cases of fake data. If False, values are empty.
53 | COOKIE_WITH_RANDOMSTRING=True
54 | # Email address of the Datenverwalters, to query them in case of infections:
55 | DATA_CONTACT=
56 | # How long visits data is kept before it is deleted:
57 | DATA_RETENTION_DAYS=14
58 | # How long status_3g field data (if any) is kept before it is deleted:
59 | DATA_RETENTION_DAYS_STATUS_3G=2
60 | # Impressum page of data processor (Verantwortlicher or Auftragsdatenverarbeiter) in EU GDPR sense:
61 | GDPR_PROCESSOR_URL=
62 | # Organization name of data processor, in double quotes: "Universität XYZ"
63 | GDPR_PROCESSOR_NAME="name"
64 | # one-line HTML snippet describing the legal basis (Rechtsgrundlage) of the data collection:
65 | LEGAL_BASIS_DE='§x und §y der verordnung'
66 | # ditto, in English language:
67 | LEGAL_BASIS_EN='Clause x and clause y of the regulation (in German)'
68 | # How long visits need to overlap before they are considered a relevant contact:
69 | MIN_OVERLAP_MINUTES=5
70 | # one-line HTML snippet with a link to the Datenschutzinformationen (in German)
71 | PRIVACYINFO_DE='Datenschutzinformationen (PDF)'
72 | # one-line HTML snippet with a link to the information about privacy protection (in English)
73 | PRIVACYINFO_EN='information about privacy protection (PDF)'
74 | # A mildly-confidential 40-letter random string to make seat URLs unguessable:
75 | SEAT_KEY=
76 | # See discussion in installation instructions:
77 | SHORTURL_PREFIX=http://a.nwesen.de/zz
78 | # False: normal operation; True: replace VisitForm by "anwesende is not in operation" msg:
79 | # (leave LEGAL_BASIS_* at their _previous_ values while STANDBY_MODE=True)
80 | STANDBY_MODE=False
81 | # Email address of the server operators, to query them in case of server trouble:
82 | TECH_CONTACT=
83 | # The meaning of local time; leave this as is:
84 | TIME_ZONE=Europe/Berlin
85 | # Whether to include email field on visit form (strongly recommended):
86 | USE_EMAIL_FIELD=True
87 | # Whether to include 3G status field on visit form:
88 | USE_STATUS_3G_FIELD=True
89 |
--------------------------------------------------------------------------------
/anwesende/templates/room/privacy.html:
--------------------------------------------------------------------------------
1 |
Information gemäß EU-Datenschutz-Grundverordnung (DSGVO) /
2 | Information according to EU General Data Protection Regulation (GDPR)
3 |
4 |
5 |
Zweck und Datennutzung / Purpose and data use
6 |
7 |
8 | DE:
9 | Die Erhebung und Nutzung erfolgt zur Eindämmung der CoViD19-Pandemie.
10 | Die Daten dienen zur Ermittlung von Kontaktpersonen und dem besseren
11 | Verständnis des Infektionsgeschehens in der Hochschule.
12 | Sie werden nur weitergegeben an und genutzt von dem Verantwortlichen
13 | (siehe unten) und den jeweils zuständigen Gesundheitsämtern.
14 | Rechtsgrundlage: {{ settings.LEGAL_BASIS_DE|safe }}.
15 |
16 |
17 | EN:
18 | Collection and use of this data serve to contain the CoViD19 pandemic.
19 | The purpose is determining contact persons and understanding better
20 | when infections occur in the university.
21 | They will be transmitted to and used by only the controller (see below)
22 | and the responsible local health authorities.
23 | Legal basis: {{ settings.LEGAL_BASIS_EN|safe }}.
24 |
25 |
26 |
27 |
Verantwortlicher / Controller
28 |
29 |
30 | DE:
31 | Die Daten werden erhoben und gespeichert von:
32 | {{ settings.GDPR_PROCESSOR_NAME }}.
33 | Kontakt: {{ settings.DATA_CONTACT }}.
34 |
35 | Verantwortlicher ist {{ room.organization }}, also die Organisation,
36 | in deren Räumen Sie hier gerade sind.
37 |
38 |
39 | EN:
40 | The data are being processed by
41 | {{ settings.GDPR_PROCESSOR_NAME }}.
42 | Contact: {{ settings.DATA_CONTACT }}.
43 |
44 | The data controller is {{ room.organization }}, the organization in whose rooms
45 | you currently are.
46 |
47 |
48 |
49 |
Welche Daten werden wie lange gespeichert? / Which data are stored for how long?
50 |
51 |
52 | DE:
53 | Gespeichert werden
54 | a) die Daten, die Sie im Formular unten selbst eingeben,
55 | b) die Daten, die auf dem QR-Code angegeben sind, den Sie eben gescannt haben
56 | [also: {{ room.organization }}; {{ room.department }}; {{ room.building }};
57 | {{ room.room }} und Sitzplatz {{ seat.seatname }}],
58 | c) der Zeitpunkt des Absendens und
59 | d) ein Zufallswert.
60 |
61 | Die Daten nach a) und d) mit Ausnahme der Uhrzeiten werden zusätzlich auch
62 | auf Ihrem Gerät in einem Cookie festgehalten,
63 | damit Sie die Formulardaten beim nächsten Mal nicht
64 | alle erneut eingeben müssen.
65 |
66 |
67 | Die gespeicherten Daten werden {{ settings.DATA_RETENTION_DAYS }} Tage lang
68 | aufbewahrt und dann automatisch gelöscht.
69 | {{ status_3g_stmt_de }}
70 |
71 |
72 | EN:
73 | The following data will be stored:
74 | a) the data you enter yourself in the form below,
75 | b) the data specified on the QR code you just scanned
76 | [i.e.: {{ room.organization }}; {{ room.department }}; {{ room.building }};
77 | {{ room.room }} and seat number {{ seat.seatname }}],
78 | c) the time of sending and
79 | d) a random value.
80 | The data according to a) and d) with the exception of the times
81 | will also be saved in a cookie on your device
82 | so that you need not enter those form data again next time.
83 |
84 |
85 | The stored data is kept for {{ settings.DATA_RETENTION_DAYS }} days
86 | and then automatically deleted.
87 | {{ status_3g_stmt_en }}
88 |
89 |
90 |
91 |
92 |
Sonstiges / Further detail
93 |
94 |
95 | DE:
96 | Für weitere Einzelheiten siehe die
97 | {{ settings.PRIVACYINFO_DE|safe }}.
98 |
99 |
100 | EN:
101 | For further details, please refer to the
102 | {{ settings.PRIVACYINFO_EN|safe }}.
103 |
104 |
--------------------------------------------------------------------------------
/anwesende/static/js/table-of-contents.js:
--------------------------------------------------------------------------------
1 | /*! tableOfContents.js v1.0.0 | (c) 2020 Chris Ferdinandi | MIT License | http://github.com/cferdinandi/table-of-contents */
2 |
3 | /*
4 | * Automatically generate a table of contents from the headings on the page
5 | * @param {String} content A selector for the element that the content is in
6 | * @param {String} target The selector for the container to render the table of contents into
7 | * @param {Object} options An object of user options [optional]
8 | */
9 | var tableOfContents = function (content, target, options) {
10 |
11 | //
12 | // Variables
13 | //
14 |
15 | // Get content
16 | var contentWrap = document.querySelector(content);
17 | var toc = document.querySelector(target);
18 | if (!contentWrap || !toc) return;
19 |
20 | // Settings & Defaults
21 | var defaults = {
22 | levels: 'h2, h3, h4, h5, h6',
23 | heading: 'Table of Contents',
24 | headingLevel: 'h2',
25 | listType: 'ul'
26 | };
27 | var settings = {};
28 |
29 | // Placeholder for headings
30 | var headings;
31 |
32 |
33 | //
34 | // Methods
35 | //
36 |
37 | /**
38 | * Merge user options into defaults
39 | * @param {Object} obj The user options
40 | */
41 | var merge = function (obj) {
42 | for (var key in defaults) {
43 | if (Object.prototype.hasOwnProperty.call(defaults, key)) {
44 | settings[key] = Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : defaults[key];
45 | }
46 | }
47 | };
48 |
49 | /**
50 | * Create an ID for a heading if one does not exist
51 | * @param {Node} heading The heading element
52 | */
53 | var createID = function (heading) {
54 | if (heading.id.length) return;
55 | heading.id = 'toc_' + heading.textContent.replace(/[^A-Za-z0-9]/g, '-');
56 | };
57 |
58 | /**
59 | * Get the HTML to indent a list a specific number of levels
60 | * @param {Integer} count The number of times to indent the list
61 | * @return {String} The HTML
62 | */
63 | var getIndent = function (count) {
64 | var html = '';
65 | for (var i = 0; i < count; i++) {
66 | html += '<' + settings.listType + '>';
67 | }
68 | return html;
69 | };
70 |
71 | /**
72 | * Get the HTML to close an indented list a specific number of levels
73 | * @param {Integer} count The number of times to "outdent" the list
74 | * @return {String} The HTML
75 | */
76 | var getOutdent = function (count) {
77 | var html = '';
78 | for (var i = 0; i < count; i++) {
79 | html += '' + settings.listType + '>';
80 | }
81 | return html;
82 | };
83 |
84 | /**
85 | * Get the HTML string to start a new list of headings
86 | * @param {Integer} diff The number of levels in or out from the current level the list is
87 | * @param {Integer} index The index of the heading in the "headings" NodeList
88 | * @return {String} The HTML
89 | */
90 | var getStartingHTML = function (diff, index) {
91 |
92 | // If indenting
93 | if (diff > 0) {
94 | return getIndent(diff);
95 | }
96 |
97 | // If outdenting
98 | if (diff < 0) {
99 | return getOutdent(Math.abs(diff));
100 | }
101 |
102 | // If it's not the first item and there's no difference
103 | if (index && !diff) {
104 | return '';
105 | }
106 |
107 | return '';
108 |
109 | };
110 |
111 | /**
112 | * Inject the table of contents into the DOM
113 | */
114 | var injectTOC = function () {
115 |
116 | // Track the current heading level
117 | var level = headings[0].tagName.slice(1);
118 | var startingLevel = level;
119 |
120 | // Cache the number of headings
121 | var len = headings.length - 1;
122 |
123 | // Inject the HTML into the DOM
124 | toc.innerHTML =
125 | '<' + settings.headingLevel + '>' + settings.heading + '' + settings.headingLevel + '>' +
126 | '<' + settings.listType + '>' +
127 | Array.prototype.map.call(headings, function (heading, index) {
128 |
129 | // Add an ID if one is missing
130 | createID(heading);
131 |
132 | // Check the heading level vs. the current list
133 | var currentLevel = heading.tagName.slice(1);
134 | var levelDifference = currentLevel - level;
135 | level = currentLevel;
136 | var html = getStartingHTML(levelDifference, index);
137 |
138 | // Generate the HTML
139 | html +=
140 | '