├── config ├── __init__.py ├── settings │ ├── __init__.py │ ├── test.py │ ├── local.py │ └── production.py ├── envs │ ├── patch_env.py │ ├── production-template.sh │ └── myenv-template.env ├── wsgi.py └── urls.py ├── anwesende ├── room │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── data │ │ │ ├── rooms1.xlsx │ │ │ └── rooms2.xlsx │ │ ├── test_views.py │ │ ├── test_migrations.py │ │ ├── test_utils.py │ │ ├── test_management.py │ │ ├── test_import.py │ │ ├── test_reports.py │ │ ├── test_forms.py │ │ ├── test_models.py │ │ └── test_excel.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0006_seat_rownumber.py │ │ ├── 0008_visit_email_can_be_blank.py │ │ ├── 0012_room_descriptor_DATA.py │ │ ├── 0010_visit_status_3g.py │ │ ├── 0007_seat_min_and_max_become_seat_last.py │ │ ├── 0009_row_and_seat_dist.py │ │ ├── 0011_room_descriptor.py │ │ ├── 0004_extend_importstep.py │ │ ├── 0001_initial.py │ │ ├── 0002_visit.py │ │ ├── 0005_number_to_seatnumber.py │ │ └── 0003_add_visit_cookie.py │ ├── apps.py │ ├── admin.py │ ├── management │ │ └── commands │ │ │ ├── make_base_data.py │ │ │ └── delete_outdated_data.py │ ├── utils.py │ ├── urls.py │ └── reports.py ├── users │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_models.py │ │ ├── test_urls.py │ │ ├── factories.py │ │ ├── test_forms.py │ │ └── test_views.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_alter_user_first_name.py │ │ └── 0001_initial.py │ ├── urls.py │ ├── apps.py │ ├── admin.py │ ├── adapters.py │ ├── forms.py │ ├── models.py │ └── views.py ├── utils │ ├── __init__.py │ ├── tests │ │ ├── data │ │ │ └── 3by3.xlsx │ │ ├── test_date.py │ │ └── test_excel.py │ ├── qrcode.py │ ├── validators.py │ ├── context_processors.py │ ├── lookup.py │ ├── date.py │ └── excel.py ├── static │ ├── fonts │ │ └── .gitkeep │ ├── sass │ │ ├── custom_bootstrap_vars.scss │ │ └── project.scss │ ├── js │ │ ├── project.js │ │ └── table-of-contents.js │ ├── images │ │ ├── favicon.ico │ │ ├── favicon-32x32.png │ │ ├── seatname-example.png │ │ ├── seatname-example-u.png │ │ └── seatname-example.pptx │ ├── pdf │ │ ├── Aushang-anwesende.pdf │ │ └── Aushang-anwesende.docx │ ├── xlsx │ │ └── roomdata-example.xlsx │ └── css │ │ └── project.css ├── __init__.py ├── contrib │ ├── __init__.py │ └── sites │ │ ├── __init__.py │ │ └── migrations │ │ ├── __init__.py │ │ ├── 0004_alter_site_options.py │ │ ├── 0002_alter_domain_unique.py │ │ ├── 0003_set_site_domain_and_name.py │ │ └── 0001_initial.py ├── templates │ ├── 403.html │ ├── 404.html │ ├── account │ │ ├── base.html │ │ ├── account_inactive.html │ │ ├── password_reset_from_key_done.html │ │ ├── signup_closed.html │ │ ├── verification_sent.html │ │ ├── password_set.html │ │ ├── password_reset_done.html │ │ ├── password_change.html │ │ ├── logout.html │ │ ├── signup.html │ │ ├── verified_email_required.html │ │ ├── password_reset.html │ │ ├── email_confirm.html │ │ ├── password_reset_from_key.html │ │ ├── login.html │ │ └── email.html │ ├── 500.html │ ├── pages │ │ └── about.html │ ├── users │ │ ├── user_form.html │ │ └── user_detail.html │ ├── room │ │ ├── _searchhits.html │ │ ├── visitsbydepartment.html │ │ ├── thankyou.html │ │ ├── visit.html │ │ ├── search.html │ │ ├── searchbyroom.html │ │ ├── visitorsbyweek.html │ │ ├── qrcodes.html │ │ ├── show_rooms.html │ │ ├── home.html │ │ └── privacy.html │ └── base.html └── conftest.py ├── .gitattributes ├── CONTRIBUTORS ├── pytest.ini ├── compose ├── postgres │ ├── maintenance │ │ ├── _sourced │ │ │ ├── constants.sh │ │ │ ├── yes_no.sh │ │ │ ├── countdown.sh │ │ │ └── messages.sh │ │ ├── backups │ │ ├── backup │ │ └── restore │ └── Dockerfile ├── traefik │ └── Dockerfile └── django │ ├── start │ ├── entrypoint │ └── Dockerfile ├── .idea ├── vcs.xml ├── modules.xml ├── misc.xml ├── webResources.xml ├── inspectionProfiles │ └── Project_Default.xml ├── runConfigurations │ ├── merge_production_dotenvs_in_dotenv.xml │ ├── migrate.xml │ ├── runserver.xml │ ├── runserver_plus.xml │ ├── pytest___.xml │ └── pytest__custom.xml └── anwesende.iml ├── .pylintrc ├── .gitlab-ci.yml ├── .editorconfig ├── setup.cfg ├── LICENSE ├── manage.py ├── requirements.in ├── README.rst └── .gitignore /config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /anwesende/room/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /anwesende/users/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /anwesende/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /anwesende/room/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /anwesende/static/fonts/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /anwesende/users/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /anwesende/room/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /anwesende/users/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /anwesende/static/sass/custom_bootstrap_vars.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Lutz Prechelt 2 | -------------------------------------------------------------------------------- /anwesende/static/js/project.js: -------------------------------------------------------------------------------- 1 | /* Project specific Javascript goes here. */ 2 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --ds=config.settings.test --reuse-db 3 | python_files = tests.py test_*.py 4 | -------------------------------------------------------------------------------- /anwesende/room/tests/data/rooms1.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prechelt/anwesende/HEAD/anwesende/room/tests/data/rooms1.xlsx -------------------------------------------------------------------------------- /anwesende/room/tests/data/rooms2.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prechelt/anwesende/HEAD/anwesende/room/tests/data/rooms2.xlsx -------------------------------------------------------------------------------- /anwesende/static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prechelt/anwesende/HEAD/anwesende/static/images/favicon.ico -------------------------------------------------------------------------------- /anwesende/utils/tests/data/3by3.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prechelt/anwesende/HEAD/anwesende/utils/tests/data/3by3.xlsx -------------------------------------------------------------------------------- /anwesende/static/images/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prechelt/anwesende/HEAD/anwesende/static/images/favicon-32x32.png -------------------------------------------------------------------------------- /anwesende/static/pdf/Aushang-anwesende.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prechelt/anwesende/HEAD/anwesende/static/pdf/Aushang-anwesende.pdf -------------------------------------------------------------------------------- /anwesende/static/images/seatname-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prechelt/anwesende/HEAD/anwesende/static/images/seatname-example.png -------------------------------------------------------------------------------- /anwesende/static/pdf/Aushang-anwesende.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prechelt/anwesende/HEAD/anwesende/static/pdf/Aushang-anwesende.docx -------------------------------------------------------------------------------- /anwesende/static/xlsx/roomdata-example.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prechelt/anwesende/HEAD/anwesende/static/xlsx/roomdata-example.xlsx -------------------------------------------------------------------------------- /anwesende/static/images/seatname-example-u.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prechelt/anwesende/HEAD/anwesende/static/images/seatname-example-u.png -------------------------------------------------------------------------------- /anwesende/static/images/seatname-example.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prechelt/anwesende/HEAD/anwesende/static/images/seatname-example.pptx -------------------------------------------------------------------------------- /compose/postgres/maintenance/_sourced/constants.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | BACKUP_DIR_PATH='/backups' 5 | BACKUP_FILE_PREFIX='backup' 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /anwesende/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.0" 2 | __version_info__ = tuple( 3 | [ 4 | int(num) if num.isdigit() else num 5 | for num in __version__.replace("-", ".", 1).split(".") 6 | ] 7 | ) 8 | -------------------------------------------------------------------------------- /compose/traefik/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM traefik:v2.3.2 2 | RUN mkdir -p /etc/traefik/acme \ 3 | && touch /etc/traefik/acme/acme.json \ 4 | && chmod 600 /etc/traefik/acme/acme.json 5 | COPY ./compose/traefik/traefik.yml /etc/traefik 6 | -------------------------------------------------------------------------------- /anwesende/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | To understand why this file is here, please read: 3 | 4 | http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django 5 | """ 6 | -------------------------------------------------------------------------------- /anwesende/contrib/sites/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | To understand why this file is here, please read: 3 | 4 | http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django 5 | """ 6 | -------------------------------------------------------------------------------- /anwesende/contrib/sites/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | To understand why this file is here, please read: 3 | 4 | http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django 5 | """ 6 | -------------------------------------------------------------------------------- /anwesende/templates/403.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Forbidden (403){% endblock %} 4 | 5 | {% block content %} 6 |

Forbidden (403)

7 | 8 |

CSRF verification failed. Request aborted.

9 | {% endblock content %} 10 | -------------------------------------------------------------------------------- /anwesende/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Page not found{% endblock %} 4 | 5 | {% block content %} 6 |

Page not found

7 | 8 |

This is not the page you were looking for.

9 | {% endblock content %} 10 | -------------------------------------------------------------------------------- /anwesende/utils/qrcode.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | import segno 4 | 5 | 6 | def qrcode_data(text, imgtype="svg"): 7 | qr = segno.make(text, error='Q') 8 | buff = io.BytesIO() 9 | qr.save(buff, scale=4, kind=imgtype) 10 | return buff.getvalue() 11 | -------------------------------------------------------------------------------- /compose/postgres/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:12.3 2 | 3 | COPY ./compose/postgres/maintenance /usr/local/bin/maintenance 4 | RUN chmod +x /usr/local/bin/maintenance/* 5 | RUN mv /usr/local/bin/maintenance/* /usr/local/bin \ 6 | && rmdir /usr/local/bin/maintenance 7 | -------------------------------------------------------------------------------- /anwesende/users/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from anwesende.users.models import User 4 | 5 | pytestmark = pytest.mark.django_db 6 | 7 | 8 | def test_user_get_absolute_url(user: User): 9 | assert user.get_absolute_url() == f"/users/{user.username}/" 10 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | load-plugins=pylint_django 3 | 4 | [FORMAT] 5 | max-line-length=120 6 | 7 | [MESSAGES CONTROL] 8 | disable=missing-docstring,invalid-name 9 | 10 | [DESIGN] 11 | max-parents=13 12 | 13 | [TYPECHECK] 14 | generated-members=REQUEST,acl_users,aq_parent,"[a-zA-Z]+_set{1,2}",save,delete 15 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /anwesende/room/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class RoomConfig(AppConfig): 5 | name = "anwesende.room" 6 | verbose_name = "Room" 7 | 8 | def ready(self): 9 | try: 10 | import anwesende.room.signals # noqa F401 11 | except ImportError: 12 | pass 13 | -------------------------------------------------------------------------------- /anwesende/templates/account/base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}{% block head_title %}{% endblock head_title %}{% endblock title %} 3 | 4 | {% block content %} 5 |
6 |
7 | {% block inner %}{% endblock %} 8 |
9 |
10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /anwesende/templates/account/account_inactive.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Account Inactive" %}{% endblock %} 6 | 7 | {% block inner %} 8 |

{% trans "Account Inactive" %}

9 | 10 |

{% trans "This account is inactive." %}

11 | {% endblock %} 12 | 13 | -------------------------------------------------------------------------------- /anwesende/utils/validators.py: -------------------------------------------------------------------------------- 1 | import django.core.exceptions as djce 2 | 3 | 4 | def validate_isprintable(value, message=None): 5 | if message is None: 6 | message = "Bitte nur sichtbare Zeichen / Printable characters only" 7 | if not isinstance(value, str) or not value.isprintable(): 8 | raise djce.ValidationError(message) 9 | -------------------------------------------------------------------------------- /anwesende/templates/account/password_reset_from_key_done.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% block head_title %}{% trans "Change Password" %}{% endblock %} 5 | 6 | {% block inner %} 7 |

{% trans "Change Password" %}

8 |

{% trans 'Your password is now changed.' %}

9 | {% endblock %} 10 | 11 | -------------------------------------------------------------------------------- /anwesende/utils/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def settings_context(_request): 5 | """Settings available by default to the templates context.""" 6 | # Note: we intentionally do NOT expose the entire settings 7 | # to prevent accidental leaking of sensitive information 8 | return {"DEBUG": settings.DEBUG} 9 | -------------------------------------------------------------------------------- /anwesende/templates/account/signup_closed.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Sign Up Closed" %}{% endblock %} 6 | 7 | {% block inner %} 8 |

{% trans "Sign Up Closed" %}

9 | 10 |

{% trans "We are sorry, but the sign up is currently closed." %}

11 | {% endblock %} 12 | 13 | -------------------------------------------------------------------------------- /anwesende/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from anwesende.users.models import User 4 | from anwesende.users.tests.factories import UserFactory 5 | 6 | 7 | @pytest.fixture(autouse=True) 8 | def media_storage(settings, tmpdir): 9 | settings.MEDIA_ROOT = tmpdir.strpath 10 | 11 | 12 | @pytest.fixture 13 | def user() -> User: 14 | return UserFactory() 15 | -------------------------------------------------------------------------------- /anwesende/users/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | import anwesende.users.views as auv 4 | 5 | app_name = "users" 6 | urlpatterns = [ 7 | path("~redirect/", view=auv.user_redirect_view, name="redirect"), 8 | path("~update/", view=auv.user_update_view, name="update"), 9 | path("/", view=auv.user_detail_view, name="detail"), 10 | ] 11 | -------------------------------------------------------------------------------- /anwesende/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Server Error{% endblock %} 4 | 5 | {% block content %} 6 |

Ooops!!! 500

7 | 8 |

Looks like something went wrong!

9 | 10 |

We track these errors automatically, but if the problem persists feel free to contact us. In the meantime, try refreshing.

11 | {% endblock content %} 12 | 13 | 14 | -------------------------------------------------------------------------------- /anwesende/users/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class UsersConfig(AppConfig): 6 | name = "anwesende.users" 7 | verbose_name = _("Users") 8 | 9 | def ready(self): 10 | try: 11 | import anwesende.users.signals # noqa F401 12 | except ImportError: 13 | pass 14 | -------------------------------------------------------------------------------- /compose/postgres/maintenance/_sourced/yes_no.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | yes_no() { 5 | declare desc="Prompt for confirmation. \$\"\{1\}\": confirmation message." 6 | local arg1="${1}" 7 | 8 | local response= 9 | read -r -p "${arg1} (y/[n])? " response 10 | if [[ "${response}" =~ ^[Yy]$ ]] 11 | then 12 | exit 0 13 | else 14 | exit 1 15 | fi 16 | } 17 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | services: 2 | - "postgres:12.3" 3 | 4 | variables: 5 | POSTGRES_HOST: postgres 6 | POSTGRES_PORT: 5432 7 | POSTGRES_DB: anwesende 8 | POSTGRES_USER: anwesende 9 | POSTGRES_PASSWORD: anwesende 10 | 11 | stages: 12 | - test 13 | 14 | pytest: 15 | stage: test 16 | image: python:3.9 17 | before_script: 18 | - pip install -r requirements.txt 19 | script: 20 | - pytest 21 | -------------------------------------------------------------------------------- /compose/postgres/maintenance/_sourced/countdown.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | countdown() { 5 | declare desc="A simple countdown. Source: https://superuser.com/a/611582" 6 | local seconds="${1}" 7 | local d=$(($(date +%s) + "${seconds}")) 8 | while [ "$d" -ge `date +%s` ]; do 9 | echo -ne "$(date -u --date @$(($d - `date +%s`)) +%H:%M:%S)\r"; 10 | sleep 0.1 11 | done 12 | } 13 | -------------------------------------------------------------------------------- /.idea/webResources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /anwesende/room/admin.py: -------------------------------------------------------------------------------- 1 | import django.contrib.admin as djca 2 | 3 | import anwesende.room.models as arm 4 | 5 | 6 | @djca.register(arm.Importstep) 7 | class ImportstepAdmin(djca.ModelAdmin): 8 | pass 9 | 10 | 11 | @djca.register(arm.Room) 12 | class RoomAdmin(djca.ModelAdmin): 13 | pass 14 | 15 | 16 | @djca.register(arm.Seat) 17 | class SeatAdmin(djca.ModelAdmin): 18 | pass 19 | 20 | 21 | @djca.register(arm.Visit) 22 | class VisitAdmin(djca.ModelAdmin): 23 | pass 24 | -------------------------------------------------------------------------------- /anwesende/utils/lookup.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Lookup 2 | import django.db.models as djdm 3 | 4 | 5 | @djdm.CharField.register_lookup 6 | class ILike(Lookup): 7 | lookup_name = 'ilike' 8 | 9 | def as_sql(self, compiler, connection): 10 | lhs, lhs_params = self.process_lhs(compiler, connection) 11 | rhs, rhs_params = self.process_rhs(compiler, connection) 12 | params = lhs_params + rhs_params 13 | return '%s ILIKE %s' % (lhs, rhs), params 14 | -------------------------------------------------------------------------------- /anwesende/templates/pages/about.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

a.nwesen.de

5 |

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 |

13 | {% endblock content %} 14 | -------------------------------------------------------------------------------- /compose/django/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | python /app/manage.py collectstatic --noinput 8 | python /app/manage.py migrate 9 | python /app/manage.py make_base_data 10 | 11 | /usr/local/bin/gunicorn config.wsgi \ 12 | --worker-class gthread \ 13 | --workers $GUNICORN_WORKERS \ 14 | --threads $GUNICORN_THREADS \ 15 | --max-requests 4000 \ 16 | --max-requests-jitter 111 \ 17 | --bind 0.0.0.0:5000 \ 18 | --chdir=/app 19 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | -------------------------------------------------------------------------------- /compose/postgres/maintenance/backups: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | ### View backups. 5 | ### 6 | ### Usage: 7 | ### $ docker-compose -f .yml (exec |run --rm) postgres backups 8 | 9 | 10 | set -o errexit 11 | set -o pipefail 12 | set -o nounset 13 | 14 | 15 | working_dir="$(dirname ${0})" 16 | source "${working_dir}/_sourced/constants.sh" 17 | source "${working_dir}/_sourced/messages.sh" 18 | 19 | 20 | message_welcome "These are the backups you have got:" 21 | 22 | ls -lht "${BACKUP_DIR_PATH}" 23 | -------------------------------------------------------------------------------- /anwesende/contrib/sites/migrations/0004_alter_site_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-11-12 16:29 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sites', '0003_set_site_domain_and_name'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='site', 15 | options={'ordering': ['domain'], 'verbose_name': 'site', 'verbose_name_plural': 'sites'}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /anwesende/templates/account/verification_sent.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Verify Your E-mail Address" %}{% endblock %} 6 | 7 | {% block inner %} 8 |

{% trans "Verify Your E-mail Address" %}

9 | 10 |

{% 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 |

{{ user.username }}

8 |
9 | {% csrf_token %} 10 | {{ form|crispy }} 11 |
12 |
13 | 14 |
15 |
16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /anwesende/templates/account/password_set.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load crispy_forms_tags %} 5 | 6 | {% block head_title %}{% trans "Set Password" %}{% endblock %} 7 | 8 | {% block inner %} 9 |

{% trans "Set Password" %}

10 | 11 |
12 | {% csrf_token %} 13 | {{ form|crispy }} 14 | 15 |
16 | {% endblock %} 17 | 18 | -------------------------------------------------------------------------------- /anwesende/templates/account/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account %} 5 | 6 | {% block head_title %}{% trans "Password Reset" %}{% endblock %} 7 | 8 | {% block inner %} 9 |

{% trans "Password Reset" %}

10 | 11 | {% if user.is_authenticated %} 12 | {% include "account/snippets/already_logged_in.html" %} 13 | {% endif %} 14 | 15 |

{% blocktrans %}We have sent you an e-mail. Please contact us if you do not receive it within a few minutes.{% endblocktrans %}

16 | {% endblock %} 17 | 18 | -------------------------------------------------------------------------------- /anwesende/templates/account/password_change.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load crispy_forms_tags %} 5 | 6 | {% block head_title %}{% trans "Change Password" %}{% endblock %} 7 | 8 | {% block inner %} 9 |

{% trans "Change Password" %}

10 | 11 |
12 | {% csrf_token %} 13 | {{ form|crispy }} 14 | 15 |
16 | {% endblock %} 17 | 18 | -------------------------------------------------------------------------------- /anwesende/users/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth import admin as auth_admin 3 | from django.contrib.auth import get_user_model 4 | 5 | from anwesende.users.forms import UserChangeForm, UserCreationForm 6 | 7 | User = get_user_model() 8 | 9 | 10 | @admin.register(User) 11 | class UserAdmin(auth_admin.UserAdmin): 12 | 13 | form = UserChangeForm 14 | add_form = UserCreationForm 15 | fieldsets = (("User", {"fields": ("name",)}),) + tuple( 16 | auth_admin.UserAdmin.fieldsets 17 | ) 18 | list_display = ["username", "name", "is_superuser"] 19 | search_fields = ["name"] 20 | -------------------------------------------------------------------------------- /anwesende/templates/account/logout.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Sign Out" %}{% endblock %} 6 | 7 | {% block inner %} 8 |

{% trans "Sign Out" %}

9 | 10 |

{% trans 'Are you sure you want to sign out?' %}

11 | 12 |
13 | {% csrf_token %} 14 | {% if redirect_field_value %} 15 | 16 | {% endif %} 17 | 18 |
19 | 20 | 21 | {% endblock %} 22 | 23 | -------------------------------------------------------------------------------- /anwesende/contrib/sites/migrations/0002_alter_domain_unique.py: -------------------------------------------------------------------------------- 1 | import django.contrib.sites.models 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [("sites", "0001_initial")] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="site", 12 | name="domain", 13 | field=models.CharField( 14 | max_length=100, 15 | unique=True, 16 | validators=[django.contrib.sites.models._simple_domain_name_validator], 17 | verbose_name="domain name", 18 | ), 19 | ) 20 | ] 21 | -------------------------------------------------------------------------------- /anwesende/room/migrations/0008_visit_email_can_be_blank.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.10 on 2021-04-23 15:43 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('room', '0007_seat_min_and_max_become_seat_last'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='visit', 15 | name='email', 16 | field=models.EmailField(blank=True, 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'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /anwesende/users/adapters.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from allauth.account.adapter import DefaultAccountAdapter 4 | from allauth.socialaccount.adapter import DefaultSocialAccountAdapter 5 | from django.conf import settings 6 | from django.http import HttpRequest 7 | 8 | 9 | class AccountAdapter(DefaultAccountAdapter): 10 | def is_open_for_signup(self, request: HttpRequest): 11 | return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) 12 | 13 | 14 | class SocialAccountAdapter(DefaultSocialAccountAdapter): 15 | def is_open_for_signup(self, request: HttpRequest, sociallogin: Any): 16 | return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) 17 | -------------------------------------------------------------------------------- /compose/postgres/maintenance/_sourced/messages.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | message_newline() { 5 | echo 6 | } 7 | 8 | message_debug() 9 | { 10 | echo -e "DEBUG: ${@}" 11 | } 12 | 13 | message_welcome() 14 | { 15 | echo -e "\e[1m${@}\e[0m" 16 | } 17 | 18 | message_warning() 19 | { 20 | echo -e "\e[33mWARNING\e[0m: ${@}" 21 | } 22 | 23 | message_error() 24 | { 25 | echo -e "\e[31mERROR\e[0m: ${@}" 26 | } 27 | 28 | message_info() 29 | { 30 | echo -e "\e[37mINFO\e[0m: ${@}" 31 | } 32 | 33 | message_suggestion() 34 | { 35 | echo -e "\e[33mSUGGESTION\e[0m: ${@}" 36 | } 37 | 38 | message_success() 39 | { 40 | echo -e "\e[32mSUCCESS\e[0m: ${@}" 41 | } 42 | -------------------------------------------------------------------------------- /anwesende/room/migrations/0012_room_descriptor_DATA.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-11-12 16:35 2 | 3 | from django.db import migrations 4 | 5 | 6 | def compute_room_descriptor(apps, schema_editor): 7 | Room = apps.get_model('room', 'Room') 8 | for room in Room.objects.all(): 9 | # must agree with Room.save(): 10 | room.descriptor = "%s;%s;%s;%s" % ( 11 | room.organization, room.department, room.building, room.room) 12 | room.save() 13 | 14 | 15 | class Migration(migrations.Migration): 16 | 17 | dependencies = [ 18 | ('room', '0011_room_descriptor'), 19 | ] 20 | 21 | operations = [ 22 | migrations.RunPython(compute_room_descriptor), # no backward migration is needed 23 | ] 24 | -------------------------------------------------------------------------------- /anwesende/users/tests/test_urls.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import resolve, reverse 3 | 4 | from anwesende.users.models import User 5 | 6 | pytestmark = pytest.mark.django_db 7 | 8 | 9 | def test_detail(user: User): 10 | assert ( 11 | reverse("users:detail", kwargs={"username": user.username}) 12 | == f"/users/{user.username}/" 13 | ) 14 | assert resolve(f"/users/{user.username}/").view_name == "users:detail" 15 | 16 | 17 | def test_update(): 18 | assert reverse("users:update") == "/users/~update/" 19 | assert resolve("/users/~update/").view_name == "users:update" 20 | 21 | 22 | def test_redirect(): 23 | assert reverse("users:redirect") == "/users/~redirect/" 24 | assert resolve("/users/~redirect/").view_name == "users:redirect" 25 | -------------------------------------------------------------------------------- /anwesende/static/sass/project.scss: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | // project specific CSS goes here 6 | 7 | //////////////////////////////// 8 | //Variables// 9 | //////////////////////////////// 10 | 11 | // Alert colors 12 | 13 | $white: #fff; 14 | $mint-green: #d6e9c6; 15 | $black: #000; 16 | $pink: #f2dede; 17 | $dark-pink: #eed3d7; 18 | $red: #b94a48; 19 | 20 | //////////////////////////////// 21 | //Alerts// 22 | //////////////////////////////// 23 | 24 | // bootstrap alert CSS, translated to the django-standard levels of 25 | // debug, info, success, warning, error 26 | 27 | .alert-debug { 28 | background-color: $white; 29 | border-color: $mint-green; 30 | color: $black; 31 | } 32 | 33 | .alert-error { 34 | background-color: $pink; 35 | border-color: $dark-pink; 36 | color: $red; 37 | } 38 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{py,rst,ini}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.py] 16 | line_length = 88 17 | known_first_party = anwesende,config 18 | multi_line_output = 3 19 | default_section = THIRDPARTY 20 | recursive = true 21 | skip = venv/ 22 | skip_glob = **/migrations/*.py 23 | include_trailing_comma = true 24 | force_grid_wrap = 0 25 | use_parentheses = true 26 | 27 | [*.{html,css,scss,json,yml}] 28 | indent_style = space 29 | indent_size = 2 30 | 31 | [*.md] 32 | trim_trailing_whitespace = false 33 | 34 | [Makefile] 35 | indent_style = tab 36 | 37 | [nginx.conf] 38 | indent_style = space 39 | indent_size = 2 40 | -------------------------------------------------------------------------------- /anwesende/templates/account/signup.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load crispy_forms_tags %} 5 | 6 | {% block head_title %}{% trans "Signup" %}{% endblock %} 7 | 8 | {% block inner %} 9 |

{% trans "Sign Up" %}

10 | 11 |

{% blocktrans %}Already have an account? Then please sign in.{% endblocktrans %}

12 | 13 | 21 | 22 | {% endblock %} 23 | 24 | -------------------------------------------------------------------------------- /anwesende/room/migrations/0010_visit_status_3g.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.10 on 2021-09-25 16:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('room', '0009_row_and_seat_dist'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='visit', 15 | name='status_3g', 16 | field=models.IntegerField(choices=[(22, 'vollständig geimpft / fully vaccinated'), (24, 'genesen / recovered'), (26, 'getestet / tested'), (87, 'weder noch / none of the above'), (88, 'unbekannt')], default=88, help_text="Siehe die Verordnung / (see regulation, in german only)", verbose_name='3G-Status gemäß §2 SchAusnahmV'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /anwesende/templates/room/_searchhits.html: -------------------------------------------------------------------------------- 1 |

{{ NUMRESULTS }} Treffer

2 |
    3 | {% for v in visits %} 4 | {% if v is None %} 5 |
  1. -- next group: --
  2. 6 | {% else %} 7 |
  3. 8 | {{ v.familyname }}, {{ v.givenname }}, 9 | {{ v.phone }}/{{ v.email }} 10 | ({{ v.zipcode }} {{ v.town }})
    11 | {{ v.present_from_dt|date:"Y-m-d" }} 12 | {{ v.present_from_dt|date:"H:i" }}–{{ v.present_to_dt|date:"H:i" }} @ 13 | {{ v.seat.room.room }}, {{ v.seat.room.building }}, 14 | {{ v.seat.room.department }}, {{ v.seat.room.organization }} 15 | 16 |
  4. 17 | {% endif %} 18 | {% endfor %} 19 |
20 | -------------------------------------------------------------------------------- /anwesende/room/migrations/0007_seat_min_and_max_become_seat_last.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.10 on 2021-01-01 20:10 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('room', '0006_seat_rownumber'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='room', 15 | name='seat_max', 16 | ), 17 | migrations.RemoveField( 18 | model_name='room', 19 | name='seat_min', 20 | ), 21 | migrations.AddField( 22 | model_name='room', 23 | name='seat_last', 24 | field=models.CharField(default='r1s1', help_text="e.g. 'r2s7' for row 2, seat 7 (14 seats total in the room)", max_length=80), 25 | preserve_default=False, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,*/.zz_builddir/* 4 | ignore = W291,W293,W503 ,E128 5 | 6 | [pycodestyle] 7 | max-line-length = 120 8 | exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,*/.zz_builddir/* 9 | 10 | [mypy] 11 | check_untyped_defs = True 12 | ignore_missing_imports = True 13 | warn_unused_ignores = False 14 | warn_redundant_casts = True 15 | warn_unused_configs = True 16 | plugins = mypy_django_plugin.main 17 | exclude = .zz_builddir 18 | 19 | [mypy.plugins.django-stubs] 20 | django_settings_module = config.settings.test 21 | 22 | [mypy-*.migrations.*] 23 | # Django migrations should not produce any errors: 24 | ignore_errors = True 25 | 26 | [coverage:run] 27 | include = anwesende/* 28 | omit = *migrations*, *tests*, *.html, anwesende/templates/account/*, 29 | plugins = 30 | django_coverage_plugin 31 | -------------------------------------------------------------------------------- /anwesende/room/migrations/0009_row_and_seat_dist.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.10 on 2021-07-11 12:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('room', '0008_visit_email_can_be_blank'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='room', 15 | name='row_dist', 16 | field=models.FloatField(default=1.4, help_text='smallest distance between people in two adjacent rows in meters'), 17 | preserve_default=False, 18 | ), 19 | migrations.AddField( 20 | model_name='room', 21 | name='seat_dist', 22 | field=models.FloatField(default=1.4, help_text='smallest distance between people in two adjacent seats in meters'), 23 | preserve_default=False, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /anwesende/templates/account/verified_email_required.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Verify Your E-mail Address" %}{% endblock %} 6 | 7 | {% block inner %} 8 |

{% trans "Verify Your E-mail Address" %}

9 | 10 | {% url 'account_email' as email_url %} 11 | 12 |

{% 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 %}

19 | 20 |

{% blocktrans %}Note: you can still change your e-mail address.{% endblocktrans %}

21 | 22 | 23 | {% endblock %} 24 | 25 | -------------------------------------------------------------------------------- /anwesende/templates/users/user_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load static %} 3 | 4 | {% block title %}User: {{ object.username }}{% endblock %} 5 | 6 | {% block content %} 7 |
8 | 9 |
10 |
11 | 12 |

{{ object.username }}

13 | {% if object.name %} 14 |

{{ object.name }}

15 | {% endif %} 16 |
17 |
18 | 19 | {% if object == request.user %} 20 | 21 |
22 | 23 |
24 | My Info 25 | E-Mail 26 | 27 |
28 | 29 |
30 | 31 | {% endif %} 32 | 33 | 34 |
35 | {% endblock content %} 36 | 37 | -------------------------------------------------------------------------------- /anwesende/templates/account/password_reset.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account %} 5 | {% load crispy_forms_tags %} 6 | 7 | {% block head_title %}{% trans "Password Reset" %}{% endblock %} 8 | 9 | {% block inner %} 10 | 11 |

{% trans "Password Reset" %}

12 | {% if user.is_authenticated %} 13 | {% include "account/snippets/already_logged_in.html" %} 14 | {% endif %} 15 | 16 |

{% trans "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." %}

17 | 18 |
19 | {% csrf_token %} 20 | {{ form|crispy }} 21 | 22 |
23 | 24 |

{% blocktrans %}Please contact us if you have any trouble resetting your password.{% endblocktrans %}

25 | {% endblock %} 26 | 27 | -------------------------------------------------------------------------------- /anwesende/users/tests/factories.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Sequence 2 | 3 | from django.contrib.auth import get_user_model 4 | from factory import Faker, post_generation 5 | from factory.django import DjangoModelFactory 6 | 7 | 8 | class UserFactory(DjangoModelFactory): 9 | 10 | username = Faker("user_name") 11 | email = Faker("email") 12 | name = Faker("name") 13 | 14 | @post_generation 15 | def password(self, create: bool, extracted: Sequence[Any], **kwargs): 16 | password = ( 17 | extracted 18 | if extracted 19 | else Faker( 20 | "password", 21 | length=42, 22 | special_chars=True, 23 | digits=True, 24 | upper_case=True, 25 | lower_case=True, 26 | ).generate(extra_kwargs={}) 27 | ) 28 | self.set_password(password) 29 | 30 | class Meta: 31 | model = get_user_model() 32 | django_get_or_create = ["username"] 33 | -------------------------------------------------------------------------------- /anwesende/room/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import tempfile 4 | import typing as tg 5 | 6 | import bs4 7 | from django.conf import settings 8 | import django.utils.timezone as djut 9 | import pytest 10 | import webtest as wt 11 | from django.urls import reverse 12 | from freezegun import freeze_time 13 | 14 | import anwesende.room.models as arm 15 | import anwesende.room.tests.makedata as artm 16 | import anwesende.utils.date as aud 17 | import anwesende.utils.excel as aue 18 | 19 | 20 | @pytest.mark.django_db 21 | def test_import_POST_nologin(django_app: wt.TestApp): 22 | # https://docs.pylonsproject.org/projects/webtest/en/latest/api.html 23 | excelfile = "anwesende/static/xlsx/roomdata-example.xlsx" 24 | importpage = django_app.get(reverse('room:import')) # can GET without login 25 | form1 = importpage.form 26 | form1['file'] = wt.Upload(excelfile) 27 | resp = form1.submit().follow() # POST requires login: must redirect 28 | assert resp.request.path == reverse('account_login') 29 | -------------------------------------------------------------------------------- /anwesende/users/forms.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import forms as admin_forms 2 | from django.contrib.auth import get_user_model 3 | from django.core.exceptions import ValidationError 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | User = get_user_model() 7 | 8 | 9 | class UserChangeForm(admin_forms.UserChangeForm): 10 | class Meta(admin_forms.UserChangeForm.Meta): 11 | model = User 12 | 13 | 14 | class UserCreationForm(admin_forms.UserCreationForm): 15 | 16 | error_message = admin_forms.UserCreationForm.error_messages.update( 17 | {"duplicate_username": _("This username has already been taken.")} 18 | ) 19 | 20 | class Meta(admin_forms.UserCreationForm.Meta): 21 | model = User 22 | 23 | def clean_username(self): 24 | username = self.cleaned_data["username"] 25 | 26 | try: 27 | User.objects.get(username=username) 28 | except User.DoesNotExist: 29 | return username 30 | 31 | raise ValidationError(self.error_messages["duplicate_username"]) 32 | -------------------------------------------------------------------------------- /anwesende/templates/room/visitsbydepartment.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

Bericht: Besuche nach Deparment

5 |

6 | Aktueller Raumbestand (#Rooms, #Seats) und 7 | Gesamtanzahl der Besuche in der Datenbank 8 | (#Visits, Zeitraum: {{ settings.DATA_RETENTION_DAYS }} Tage) 9 |

10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% for stat in stats %} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {% endfor %} 30 | 31 |
OrganizationDepartment#Rooms#Seats#Visits
{{ stat.organization }}{{ stat.department }}{{ stat.rooms }}{{ stat.seats }}{{ stat.visits }}
32 |

Zurück

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 |

{% trans "Confirm E-mail Address" %}

11 | 12 | {% if confirmation %} 13 | 14 | {% user_display confirmation.email_address.user as user_display %} 15 | 16 |

{% blocktrans with confirmation.email_address.email as email %}Please confirm that {{ email }} is an e-mail address for user {{ user_display }}.{% endblocktrans %}

17 | 18 |
19 | {% csrf_token %} 20 | 21 |
22 | 23 | {% else %} 24 | 25 | {% url 'account_email' as email_url %} 26 | 27 |

{% blocktrans %}This e-mail confirmation link expired or is invalid. Please issue a new e-mail confirmation request.{% endblocktrans %}

28 | 29 | {% endif %} 30 | 31 | {% endblock %} 32 | 33 | -------------------------------------------------------------------------------- /anwesende/templates/account/password_reset_from_key.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load crispy_forms_tags %} 5 | {% block head_title %}{% trans "Change Password" %}{% endblock %} 6 | 7 | {% block inner %} 8 |

{% 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 |
16 | {% csrf_token %} 17 | {{ form|crispy }} 18 | 19 |
20 | {% else %} 21 |

{% trans 'Your password is now changed.' %}

22 | {% endif %} 23 | {% endif %} 24 | {% endblock %} 25 | 26 | -------------------------------------------------------------------------------- /compose/django/entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | 8 | 9 | 10 | if [ -z "${POSTGRES_USER}" ]; then 11 | base_postgres_image_default_user='postgres' 12 | export POSTGRES_USER="${base_postgres_image_default_user}" 13 | fi 14 | export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" 15 | 16 | postgres_ready() { 17 | python << END 18 | import sys 19 | 20 | import psycopg2 21 | 22 | try: 23 | psycopg2.connect( 24 | dbname="${POSTGRES_DB}", 25 | user="${POSTGRES_USER}", 26 | password="${POSTGRES_PASSWORD}", 27 | host="${POSTGRES_HOST}", 28 | port="${POSTGRES_PORT}", 29 | ) 30 | except psycopg2.OperationalError: 31 | sys.exit(-1) 32 | sys.exit(0) 33 | 34 | END 35 | } 36 | until postgres_ready; do 37 | >&2 echo 'Waiting for PostgreSQL to become available...' 38 | sleep 1 39 | done 40 | >&2 echo 'PostgreSQL is available' 41 | 42 | chown django:django /djangolog 43 | >&2 echo exec runuser -g django -u django -- "$@" 44 | exec runuser -g django -u django -- "$@" 45 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | from pathlib import Path 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") 8 | 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError: 12 | # The above import may fail for some other reason. Ensure that the 13 | # issue is really that Django is missing to avoid masking other 14 | # exceptions on Python 2. 15 | try: 16 | import django # noqa 17 | except ImportError: 18 | raise ImportError( 19 | "Couldn't import Django. Are you sure it's installed and " 20 | "available on your PYTHONPATH environment variable? Did you " 21 | "forget to activate a virtual environment?" 22 | ) 23 | 24 | raise 25 | 26 | # This allows easy placement of apps within the interior 27 | # anwesende directory. 28 | current_path = Path(__file__).parent.resolve() 29 | sys.path.append(str(current_path / "anwesende")) 30 | 31 | execute_from_command_line(sys.argv) 32 | -------------------------------------------------------------------------------- /anwesende/room/migrations/0011_room_descriptor.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-11-12 16:30 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('room', '0010_visit_status_3g'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='room', 15 | name='descriptor', 16 | field=models.CharField(default='', help_text="a materialization: 'organization;department;building;room' for search", max_length=320), 17 | preserve_default=False, 18 | ), 19 | migrations.AlterField( 20 | model_name='visit', 21 | name='status_3g', 22 | field=models.IntegerField(choices=[(22, 'vollständig geimpft / fully vaccinated'), (24, 'genesen / recovered'), (26, 'getestet / tested'), (87, 'weder noch / none of the above'), (88, 'unbekannt')], default=88, help_text="Siehe die Verordnung / (see regulation, in german only)", verbose_name='3G-Status gemäß §2 SchAusnahmV / vaccination status'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /.idea/runConfigurations/merge_production_dotenvs_in_dotenv.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 22 | -------------------------------------------------------------------------------- /anwesende/templates/room/thankyou.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

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 |

29 | {% else %} 30 |

31 | Show occupied seats 33 |

34 | {% endif %} 35 | {% endblock content %} 36 | -------------------------------------------------------------------------------- /compose/postgres/maintenance/backup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | ### Create a database backup. 5 | ### 6 | ### Usage: 7 | ### $ docker-compose -f .yml (exec |run --rm) postgres backup 8 | 9 | 10 | set -o errexit 11 | set -o pipefail 12 | set -o nounset 13 | 14 | 15 | working_dir="$(dirname ${0})" 16 | source "${working_dir}/_sourced/constants.sh" 17 | source "${working_dir}/_sourced/messages.sh" 18 | 19 | 20 | message_welcome "Backing up the '${POSTGRES_DB}' database..." 21 | 22 | 23 | if [[ "${POSTGRES_USER}" == "postgres" ]]; then 24 | message_error "Backing up as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with another one and try again." 25 | exit 1 26 | fi 27 | 28 | export PGHOST="${POSTGRES_HOST}" 29 | export PGPORT="${POSTGRES_PORT}" 30 | export PGUSER="${POSTGRES_USER}" 31 | export PGPASSWORD="${POSTGRES_PASSWORD}" 32 | export PGDATABASE="${POSTGRES_DB}" 33 | 34 | backup_filename="${BACKUP_FILE_PREFIX}_$(date +'%Y_%m_%dT%H_%M_%S').sql.gz" 35 | pg_dump | gzip > "${BACKUP_DIR_PATH}/${backup_filename}" 36 | 37 | 38 | message_success "'${POSTGRES_DB}' database backup '${backup_filename}' has been created and placed in '${BACKUP_DIR_PATH}'." 39 | -------------------------------------------------------------------------------- /anwesende/users/models.py: -------------------------------------------------------------------------------- 1 | import django.contrib.auth.models as djcam 2 | from django.db.models import CharField 3 | from django.urls import reverse 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | 7 | class User(djcam.AbstractUser): 8 | """Default user for anwesende.""" 9 | STAFF_GROUP = "datenverwalter" 10 | #: First and last name do not cover name patterns around the globe: 11 | name = CharField(_("Name of User"), blank=True, max_length=255) 12 | 13 | def get_absolute_url(self): 14 | return reverse("users:detail", kwargs={"username": self.username}) 15 | 16 | def assign_datenverwalter_group(self) -> None: 17 | group = self.get_datenverwalter_group() 18 | self.groups.add(group) 19 | 20 | def is_datenverwalter(self) -> bool: 21 | return self.groups.filter(id=self.get_datenverwalter_group().id).exists() 22 | 23 | @classmethod 24 | def get_datenverwalter_group(cls) -> djcam.Group: 25 | if not djcam.Group.objects.filter(name=cls.STAFF_GROUP).exists(): 26 | fresh_group = djcam.Group.objects.create(name=cls.STAFF_GROUP) # noqa 27 | return djcam.Group.objects.get_by_natural_key(cls.STAFF_GROUP) 28 | -------------------------------------------------------------------------------- /anwesende/contrib/sites/migrations/0003_set_site_domain_and_name.py: -------------------------------------------------------------------------------- 1 | """ 2 | To understand why this file is here, please read: 3 | 4 | http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django 5 | """ 6 | from django.conf import settings 7 | from django.db import migrations 8 | 9 | 10 | def update_site_forward(apps, schema_editor): 11 | """Set site domain and name.""" 12 | Site = apps.get_model("sites", "Site") 13 | Site.objects.update_or_create( 14 | id=settings.SITE_ID, 15 | defaults={ 16 | "domain": "a.nwesen.de", 17 | "name": "anwesende", 18 | }, 19 | ) 20 | 21 | 22 | def update_site_backward(apps, schema_editor): 23 | """Revert site domain and name to default.""" 24 | Site = apps.get_model("sites", "Site") 25 | Site.objects.update_or_create( 26 | id=settings.SITE_ID, defaults={"domain": "example.com", "name": "example.com"} 27 | ) 28 | 29 | 30 | class Migration(migrations.Migration): 31 | 32 | dependencies = [("sites", "0002_alter_domain_unique")] 33 | 34 | operations = [migrations.RunPython(update_site_forward, update_site_backward)] 35 | -------------------------------------------------------------------------------- /anwesende/static/css/project.css: -------------------------------------------------------------------------------- 1 | .alert-debug { 2 | color: black; 3 | background-color: white; 4 | border-color: #d6e9c6; 5 | } 6 | 7 | .endfloat { 8 | clear: both; 9 | } 10 | 11 | .alert-error { 12 | color: #b94a48; 13 | background-color: #f2dede; 14 | border-color: #eed3d7; 15 | } 16 | 17 | a:link { 18 | color: #99CC00; 19 | filter: brightness(70%); 20 | } 21 | 22 | a:visited { 23 | color: #99CC00; 24 | filter: brightness(50%); 25 | } 26 | 27 | .bg-light { 28 | background-color: #99CC00 !important; 29 | } 30 | 31 | .languagemark { 32 | color: #cc0000; 33 | font-weight: bold; 34 | } 35 | 36 | .qrb-label { 37 | color: #222222; 38 | font-size: 100%; 39 | } 40 | 41 | .qrb-item { 42 | color: black; 43 | font-size: 120%; 44 | } 45 | 46 | .qrb-markeditem { 47 | color: black; 48 | font-size: 120%; 49 | font-weight: bold; 50 | } 51 | 52 | .qrb-largeitem { 53 | color: black; 54 | font-size: 300%; 55 | font-weight: bold; 56 | } 57 | 58 | .qrblock { 59 | padding-top: 2ex; 60 | width: 300mm; 61 | } 62 | 63 | .qrcode { 64 | width: 100mm; 65 | max-width: 100%; 66 | } 67 | 68 | sup { 69 | font-weight: 700; 70 | color: #cc0000; 71 | } 72 | 73 | var { 74 | font-weight: 500; 75 | } -------------------------------------------------------------------------------- /compose/django/Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | FROM python:3.9-slim-bullseye 3 | 4 | ARG DJANGO_UID 5 | ARG DJANGO_GID 6 | ENV PYTHONUNBUFFERED 1 7 | 8 | RUN apt-get update \ 9 | # dependencies for building Python packages: 10 | && apt-get install -y build-essential \ 11 | # psycopg2 dependencies: 12 | && apt-get install -y libpq-dev \ 13 | # Translations dependencies: 14 | && apt-get install -y gettext \ 15 | # silence a 'deferred' warning: 16 | && apt-get install -y apt-utils \ 17 | # cleaning up unused files: 18 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ 19 | && rm -rf /var/lib/apt/lists/* 20 | 21 | RUN addgroup --gid $DJANGO_GID django \ 22 | && adduser --system --uid $DJANGO_UID --ingroup django django 23 | 24 | # Requirements are installed here to ensure they will be cached. 25 | COPY ./requirements.txt /requirements.txt 26 | RUN pip --no-cache-dir install -r /requirements.txt 27 | 28 | COPY --chown=django:django ./compose/django/entrypoint /entrypoint 29 | RUN sed -i 's/\r$//g' /entrypoint && chmod +x /entrypoint 30 | 31 | 32 | COPY --chown=django:django ./compose/django/start /start 33 | RUN sed -i 's/\r$//g' /start && chmod +x /start 34 | COPY --chown=django:django . /app 35 | 36 | WORKDIR /app 37 | 38 | ENTRYPOINT ["/entrypoint"] 39 | -------------------------------------------------------------------------------- /anwesende/users/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from anwesende.users.forms import UserCreationForm 4 | from anwesende.users.tests.factories import UserFactory 5 | 6 | pytestmark = pytest.mark.django_db 7 | 8 | 9 | class TestUserCreationForm: 10 | def test_clean_username(self): 11 | # A user with proto_user params does not exist yet. 12 | proto_user = UserFactory.build() 13 | 14 | form = UserCreationForm( 15 | { 16 | "username": proto_user.username, 17 | "password1": proto_user._password, 18 | "password2": proto_user._password, 19 | } 20 | ) 21 | 22 | assert form.is_valid() 23 | assert form.clean_username() == proto_user.username 24 | 25 | # Creating a user. 26 | form.save() 27 | 28 | # The user with proto_user params already exists, 29 | # hence cannot be created. 30 | form = UserCreationForm( 31 | { 32 | "username": proto_user.username, 33 | "password1": proto_user._password, 34 | "password2": proto_user._password, 35 | } 36 | ) 37 | 38 | assert not form.is_valid() 39 | assert len(form.errors) == 1 40 | assert "username" in form.errors 41 | -------------------------------------------------------------------------------- /anwesende/templates/room/visit.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load crispy_forms_tags %} 3 | 4 | {% block content %} 5 | {% if settings.STANDBY_MODE %} 6 | 7 |

DE: Nicht in Betrieb

8 |

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 | 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 | 33 | 34 | -------------------------------------------------------------------------------- /.idea/runConfigurations/runserver_plus.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 |
25 | 26 | 27 | 28 |
29 | 30 | {% include "socialaccount/snippets/login_extra.html" %} 31 | 32 | {% else %} 33 |

{% blocktrans %}If you have not created an account yet, then please 34 | sign up first.{% endblocktrans %}

35 | {% endif %} 36 | 37 | 46 | 47 | {% endblock %} 48 | 49 | -------------------------------------------------------------------------------- /anwesende/utils/tests/test_excel.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import os 3 | import tempfile 4 | 5 | import pytest # noqa 6 | 7 | import anwesende.utils.excel as aue 8 | 9 | the_3by3_file = "anwesende/utils/tests/data/3by3.xlsx" 10 | 11 | 12 | def test_read_excel_as_columnsdict(): 13 | cd = aue.read_excel_as_columnsdict(the_3by3_file) 14 | assert set(cd.keys()) == set(["A-str", "B-int", "C-str"]) 15 | assert cd['A-str'] == ["string1", "string2"] 16 | assert cd['B-int'] == ['1', '4711'] # not [1, 4711], forced result 17 | assert cd['C-str'] == ['', '4711'] # not [None, 4711], forced result 18 | # openpyxl may return numbers even if the cell format is text. 19 | # The excel module forces the results to be str, and None to be "". 20 | 21 | 22 | def test_write_excel_from_rowslists(): 23 | TestTuple = collections.namedtuple('TestTuple', 'a b c dee') 24 | testdata = dict(test=[ 25 | TestTuple(a="a1", b="b1", c="c1", dee="d1"), 26 | TestTuple(a="a2", b="b2", c="c2", dee="d2"), 27 | ]) 28 | with tempfile.NamedTemporaryFile(prefix="test", suffix=".xlsx", 29 | delete=False) as fh: 30 | filename = fh.name # file is deleted in 'finally' clause 31 | print(filename) 32 | try: 33 | aue.write_excel_from_rowslists(filename, testdata) 34 | # import time 35 | # time.sleep(60) 36 | columns = aue.read_excel_as_columnsdict(filename) 37 | print(columns) 38 | assert list(columns.keys()) == ['a', 'b', 'c', 'dee'] 39 | assert len(columns['dee']) == 2 40 | assert columns['c'][1] == "c2" 41 | finally: 42 | os.unlink(filename) 43 | -------------------------------------------------------------------------------- /anwesende/templates/room/search.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load crispy_forms_tags %} 3 | 4 | {% block content %} 5 |

Suche nach Besuchen von Personen

6 |
    7 |
  1. 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 |
  2. 12 |
  3. 13 | Prüfen Sie das Suchergebnis. Zu viele Treffer? 14 | Dann schärfere Kriterien benutzen. 15 | Am besten eignet sich normalerweise die Emailadresse. 16 |
  4. 17 |
  5. 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 |
  6. 22 |
23 |

24 | Im Formular unten steht das Prozentzeichen % für Teile, die 25 | egal oder unbekannt sind. 26 |

27 | {% crispy form %} 28 | 29 | {% if display_switch == 'invalid' %} 30 | 31 | {% elif display_switch == 'too_many_results' %} 32 |

{{ NUMRESULTS }} results

33 |

34 | Will show at most {{ LIMIT }} results. 35 | Please use more specific search criteria. 36 |

37 | {% elif display_switch == 'submit_visit' or display_switch == 'submit_visitgroup' %} 38 | {% include "room/_searchhits.html" %} 39 | {% else %} 40 |

Something is wrong! (display_switch {{ display_switch }})

41 | {% endif %} 42 | 43 |

44 | Suche per Raum (statt per Person) 46 |

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 |
  1. 8 | Beschreiben Sie den gewünschten Raum mit der Suchzeile unten und 9 | geben Sie einen passenden Zeitraum an. 10 |
  2. 11 |
  3. 12 | Prüfen Sie das Suchergebnis. Mehr als ein Treffer? 13 | Dann schärfere Kriterien benutzen. 14 |
  4. 15 |
  5. 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 |
  6. 20 |
21 | 22 | {% crispy form %} 23 | 24 | {% if display_switch == 'invalid' %} 25 | 26 | {% elif display_switch == 'too_many_results' %} 27 |

{{ NUMRESULTS }} results

28 |

29 | Will show at most {{ LIMIT }} results. 30 | Please use more specific search criteria. 31 |

32 | {% elif display_switch == 'submit_room' %} 33 |

{{ NUMRESULTS }} Treffer

34 |
    35 | {% for r in rooms %} 36 | {% if r is None %} 37 |
  1. -- next group: --
  2. 38 | {% else %} 39 |
  3. 40 | {{ r.organization }}; 41 | {{ r.department }}; 42 | {{ r.building }}; 43 | {{ r.room }} 44 |
  4. 45 | {% endif %} 46 | {% endfor %} 47 |
48 | {% elif display_switch == 'submit_visitgroup' %} 49 | {% include "room/_searchhits.html" %} 50 | {% else %} 51 |

Something is wrong! (display_switch {{ display_switch }})

52 | {% endif %} 53 | 54 |

55 | Suche per Person (statt per Raum) 57 |

58 | 59 | {% endblock content %} 60 | -------------------------------------------------------------------------------- /.idea/anwesende.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 28 | 29 | 31 | 32 | 34 | 35 | 41 | 42 | 43 | 45 | -------------------------------------------------------------------------------- /anwesende/room/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.10 on 2020-10-09 12:32 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Importstep', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('when', models.DateTimeField(auto_now_add=True)), 20 | ('randomkey', models.CharField(max_length=80)), 21 | ], 22 | ), 23 | migrations.CreateModel( 24 | name='Room', 25 | fields=[ 26 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 27 | ('organization', models.CharField(max_length=80)), 28 | ('department', models.CharField(max_length=80)), 29 | ('building', models.CharField(max_length=80)), 30 | ('room', models.CharField(max_length=80)), 31 | ('seat_min', models.IntegerField()), 32 | ('seat_max', models.IntegerField()), 33 | ('importstep', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='room.Importstep')), 34 | ], 35 | ), 36 | migrations.CreateModel( 37 | name='Seat', 38 | fields=[ 39 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 40 | ('number', models.IntegerField()), 41 | ('hash', models.CharField(db_index=True, max_length=80, unique=True)), 42 | ('room', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='room.Room')), 43 | ], 44 | ), 45 | migrations.AddConstraint( 46 | model_name='room', 47 | constraint=models.UniqueConstraint(fields=('organization', 'department', 'building', 'room'), name='orgdeptbldgroomunique_room'), 48 | ), 49 | ] 50 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | StringGenerator # https://pypi.org/project/StringGenerator 2 | WSGIProxy2 # 3 | Werkzeug # https://github.com/pallets/werkzeug 4 | argon2-cffi # https://github.com/hynek/argon2_cffi 5 | arrow # https://arrow.readthedocs.io/en/latest/ 6 | black # https://github.com/ambv/black 7 | coverage # https://github.com/nedbat/coveragepy 8 | django-allauth # https://github.com/pennersr/django-allauth 9 | django-coverage-plugin # https://github.com/nedbat/django_coverage_plugin 10 | django-crispy-forms # https://github.com/django-crispy-forms/django-crispy-forms 11 | django-debug-toolbar # https://github.com/jazzband/django-debug-toolbar 12 | django-environ # https://github.com/joke2k/django-environ 13 | django-extensions # https://github.com/django-extensions/django-extensions 14 | django-model-utils # https://github.com/jazzband/django-model-utils 15 | django-stubs # https://github.com/typeddjango/django-stubs 16 | django-test-migrations # https://github.com/wemake-services/django-test-migrations 17 | django-vanilla-views 18 | django-webtest # https://github.com/django-webtest/django-webtest 19 | django<3.3 # https://www.djangoproject.com/ 20 | factory-boy<3.1 # https://github.com/FactoryBoy/factory_boy ##Faker.generate() was removed 21 | flake8-isort # https://github.com/gforcada/flake8-isort 22 | flake8 # https://github.com/PyCQA/flake8 23 | gunicorn # https://github.com/benoitc/gunicorn 24 | ipdb # https://github.com/gotcha/ipdb 25 | mypy # https://github.com/python/mypy 26 | openpyxl # https://openpyxl.readthedocs.io 27 | pip-tools 28 | pre-commit # https://github.com/pre-commit/pre-commit 29 | psycopg2-binary<2.9 # https://github.com/psycopg/psycopg2 ##2.9 has UTC problem in Django 3.0 30 | pylint-django # https://github.com/PyCQA/pylint-django 31 | pytest-cov # https://github.com/pytest-dev/pytest-cov 32 | pytest-django # https://github.com/pytest-dev/pytest-django 33 | pytest-freezegun # https://github.com/ktosiek/pytest-freezegun 34 | pytest-sugar # https://github.com/Frozenball/pytest-sugar 35 | pytest # https://github.com/pytest-dev/pytest 36 | python-slugify # https://github.com/un33k/python-slugify 37 | pytz # https://github.com/stub42/pytz 38 | segno # https://segno.readthedocs.io 39 | whitenoise # https://github.com/evansd/whitenoise 40 | -------------------------------------------------------------------------------- /anwesende/room/migrations/0002_visit.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.10 on 2020-10-12 16:46 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('room', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Visit', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('givenname', models.CharField(help_text='Rufname / the firstname by which you are commonly known', max_length=80, verbose_name='Vorname / Given name')), 20 | ('familyname', models.CharField(help_text='Wie im Ausweis angegeben / as shown in your passport', max_length=80, verbose_name='Familienname / Family name')), 21 | ('street_and_number', models.CharField(max_length=80, verbose_name='Straße und Hausnummer / Street and number')), 22 | ('zipcode', models.CharField(max_length=80, validators=[django.core.validators.RegexValidator(message='5 Ziffern bitte / 5 digits, please', regex='^\\d{5}$')], verbose_name='Postleitzahl / Postal code')), 23 | ('town', models.CharField(max_length=80, verbose_name='Ort / Town')), 24 | ('phone', models.CharField(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 TelefonnummerWrong format as a phone number', regex='^\\+\\d\\d[\\d /-]+$')], verbose_name='Mobilfunknummer / Mobile phone number')), 25 | ('email', models.EmailField(help_text='Bitte immer die gleiche benutzen! / Please use the same one each time', max_length=80, verbose_name='Emailadresse / Email address')), 26 | ('present_from_dt', models.DateTimeField(max_length=80)), 27 | ('present_to_dt', models.DateTimeField(max_length=80)), 28 | ('submission_dt', models.DateTimeField(auto_now_add=True)), 29 | ('seat', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='room.Seat')), 30 | ], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /anwesende/room/tests/test_management.py: -------------------------------------------------------------------------------- 1 | import django.contrib.auth.models as djcam 2 | import pytest 3 | 4 | import anwesende.room.management.commands.delete_outdated_data as delete_outdated_data 5 | import anwesende.room.management.commands.make_base_data as make_base_data 6 | import anwesende.room.models as arm 7 | import anwesende.room.tests.makedata as artm 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_make_base_data(): 12 | assert djcam.Group.objects.count() == 0 13 | make_base_data.Command().handle() 14 | dvgroup = djcam.Group.objects.get() # must exist now 15 | make_base_data.Command().handle() # must be idempotent 16 | assert dvgroup == djcam.Group.objects.get() 17 | 18 | 19 | @pytest.mark.django_db 20 | def test_delete_outdated_data(freezer, caplog): 21 | # --- make batch 1 of visits (to be deleted): 22 | freezer.move_to("2020-10-01T12:00") 23 | artm.make_user_rooms_seats_visits(seat_last="r1s5", visitsN=25) 24 | assert arm.Visit.objects.count() == 2 * 25 25 | # --- make batch 2 of visits (status_3g just to be deleted): 26 | freezer.move_to("2020-10-30T11:00") 27 | artm.make_user_rooms_seats_visits(seat_last="r1s4", visitsN=1) 28 | assert arm.Visit.objects.count() == 2 * (25 + 1) 29 | # --- make batch 3 of visits (status_3g just still to be kept): 30 | freezer.move_to("2020-10-30T13:00") 31 | artm.make_user_rooms_seats_visits(seat_last="r1s4", visitsN=1) 32 | assert arm.Visit.objects.count() == 2 * (25 + 1 + 1) 33 | # --- make later batch 4: 34 | freezer.move_to("2020-11-01T12:00") 35 | artm.make_user_rooms_seats_visits(seat_last="r2s2", visitsN=33) 36 | assert arm.Room.objects.count() == 2 * (1 + 1 + 1 + 1) 37 | assert arm.Visit.objects.count() == 2 * (25 + 1 + 1 + 33) 38 | # --- delete batch 1 and check: 39 | freezer.move_to("2020-11-01T12:01") 40 | # delete batch 1, delete status_3g from batch 2, keep everything else: 41 | delete_outdated_data.Command().handle() 42 | assert arm.Visit.objects.count() == 2 * (1 + 1 + 33) 43 | assert arm.Visit.objects.filter(status_3g=arm.G_UNKNOWN).count() == 2 * 1 44 | msg = [rec.msg for rec in caplog.records] 45 | assert "deleting 50 " in msg[0] 46 | assert "(of 120 existing)" in msg[0] 47 | assert "cleansing 2 " in msg[1] 48 | assert "(of 70 existing)" in msg[1] 49 | -------------------------------------------------------------------------------- /anwesende/room/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.staticfiles.storage import staticfiles_storage 2 | from django.urls import path 3 | from django.views.generic.base import RedirectView 4 | 5 | import anwesende.room.views as arv 6 | 7 | app_name = "room" 8 | urlpatterns = [ 9 | path("", 10 | view=arv.HomeView.as_view(), name="home"), 11 | path("faq", 12 | view=arv.FAQView.as_view(), name="faq"), 13 | path("import", 14 | view=arv.ImportView.as_view(), name="import"), 15 | path("qrcodes/", 16 | view=arv.QRcodesByImportView.as_view(), name="qrcodes-byimport"), 17 | path("qrcodes///", 18 | view=arv.QRcodesByRoomsView.as_view(), name="qrcodes-byorgdepbld"), 19 | path("qrcodes////", 20 | view=arv.QRcodesByRoomsView.as_view(), name="qrcodes-byorgdepbldrm"), 21 | path("qrcode/", 22 | view=arv.QRcodeView.as_view(), name="qrcode"), 23 | path("S", 24 | view=arv.VisitView.as_view(), name="visit"), 25 | path("search", 26 | view=arv.SearchView.as_view(), name="search"), 27 | path("search_room", 28 | view=arv.SearchByRoomView.as_view(), name="searchroom"), 29 | path("show_rooms", 30 | view=arv.ShowRoomsView.as_view(), name="show-rooms"), 31 | path("show_rooms//", 32 | view=arv.ShowRoomsView.as_view(), name="show-rooms-department"), 33 | path("show_rooms///", 34 | view=arv.ShowRoomsView.as_view(), name="show-rooms-building"), 35 | path("report_dept", 36 | view=arv.VisitsByDepartmentView.as_view(), name="report_dept"), 37 | path("report_week", 38 | view=arv.VisitorsByWeekView.as_view(), name="report_week"), 39 | path("thankyou/S", 40 | view=arv.ThankyouView.as_view(), name="thankyou"), 41 | path("thankyou/S/seats", 42 | view=arv.ThankyouView.as_view(with_seats=True), name="thankyouseats"), 43 | path("thankyou/visitors_presentN=", 44 | view=arv.LegacyThankyouView.as_view(), name="legacy_thankyou"), 45 | path("uncookie", 46 | view=arv.UncookieView.as_view(), name="uncookie"), 47 | path('favicon.ico', RedirectView.as_view( 48 | url=staticfiles_storage.url('images/favicon.ico'))) 49 | ] 50 | -------------------------------------------------------------------------------- /anwesende/templates/room/visitorsbyweek.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load crispy_forms_tags %} 3 | 4 | {% block content %} 5 |

Bericht: Besucher_innen nach Woche

6 |

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 |

16 | 17 | {% crispy form %} 18 | 19 | 20 | {% if stats %} 21 | 22 |

Ergebnis für '{{ descriptor }}'

23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {% for stat in stats %} 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | {% endfor %} 50 | 51 |
week from#orgs#deps#bldgs#rooms#visits#peoplev/p
{{ stat.week_from|date:"Y-m-d H:i" }}{{ stat.organizationsN }}{{ stat.departmentsN }}{{ stat.buildingsN }}{{ stat.roomsN }}{{ stat.visitsN }}{{ stat.visitorsN }}{{ stat.visits_per_visitor|floatformat:1 }}
52 | 53 |
    54 |
  • 55 | #orgs, #deps, #bldgs, #rooms: 56 | Anzahl verschiedener organizations, departments, buildings, rooms, 57 | auf die die Besuche entfallen sind. 58 |
  • 59 |
  • 60 | #visits: Anzahl von Besuchen (Registrierungen, 61 | ggf. auch die selbe Person in der selben Veranstaltung mehrfach) 62 |
  • 63 |
  • 64 | #people: Anzahl verschiedener Personen (genauer: {{ id_attribute }}) 65 | über die Besuche hinweg 66 |
  • 67 |
  • 68 | v/p: Besuche pro Person: Quotient von #visits und #people 69 |
  • 70 |
71 | 72 | {% endif %} 73 |

Zurück

74 | {% endblock content %} 75 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | anwesende 2 | ========= 3 | 4 | Simple attendance registration for universities having pandemics 5 | 6 | .. image:: https://img.shields.io/badge/built%20with-Cookiecutter%20Django-ff69b4.svg 7 | :target: https://github.com/pydanny/cookiecutter-django/ 8 | :alt: Built with Cookiecutter Django 9 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 10 | :target: https://github.com/ambv/black 11 | :alt: Black code style 12 | 13 | 14 | :License: MIT 15 | 16 | 17 | Settings 18 | -------- 19 | 20 | Moved to settings_. 21 | 22 | .. _settings: http://cookiecutter-django.readthedocs.io/en/latest/settings.html 23 | 24 | Basic Commands 25 | -------------- 26 | 27 | Setting Up Your Users 28 | ^^^^^^^^^^^^^^^^^^^^^ 29 | 30 | * To create a **normal user account**, just go to Sign Up and fill out the form. Once you submit it, you'll see a "Verify Your E-mail Address" page. Go to your console to see a simulated email verification message. Copy the link into your browser. Now the user's email should be verified and ready to go. 31 | 32 | * To create an **superuser account**, use this command:: 33 | 34 | $ python manage.py createsuperuser 35 | 36 | For convenience, you can keep your normal user logged in on Chrome and your superuser logged in on Firefox (or similar), so that you can see how the site behaves for both kinds of users. 37 | 38 | Type checks 39 | ^^^^^^^^^^^ 40 | 41 | Running type checks with mypy: 42 | 43 | :: 44 | 45 | $ mypy anwesende 46 | 47 | Test coverage 48 | ^^^^^^^^^^^^^ 49 | 50 | To run the tests, check your test coverage, and generate an HTML coverage report:: 51 | 52 | $ coverage run -m pytest 53 | $ coverage html 54 | $ open htmlcov/index.html 55 | 56 | Running tests with py.test 57 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 58 | 59 | :: 60 | 61 | $ pytest 62 | 63 | Live reloading and Sass CSS compilation 64 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 65 | 66 | Moved to `Live reloading and SASS compilation`_. 67 | 68 | .. _`Live reloading and SASS compilation`: http://cookiecutter-django.readthedocs.io/en/latest/live-reloading-and-sass-compilation.html 69 | 70 | 71 | 72 | 73 | 74 | Deployment 75 | ---------- 76 | 77 | The following details how to deploy this application. 78 | 79 | 80 | 81 | Docker 82 | ^^^^^^ 83 | 84 | See detailed `cookiecutter-django Docker documentation`_. 85 | 86 | .. _`cookiecutter-django Docker documentation`: http://cookiecutter-django.readthedocs.io/en/latest/deployment-with-docker.html 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /anwesende/users/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth.models import AnonymousUser 3 | from django.http.response import Http404 4 | from django.test import RequestFactory 5 | 6 | from anwesende.users.models import User 7 | from anwesende.users.tests.factories import UserFactory 8 | from anwesende.users.views import UserRedirectView, UserUpdateView, user_detail_view 9 | 10 | pytestmark = pytest.mark.django_db 11 | 12 | 13 | class TestUserUpdateView: 14 | """ 15 | TODO: 16 | extracting view initialization code as class-scoped fixture 17 | would be great if only pytest-django supported non-function-scoped 18 | fixture db access -- this is a work-in-progress for now: 19 | https://github.com/pytest-dev/pytest-django/pull/258 20 | """ 21 | 22 | def test_get_success_url(self, user: User, rf: RequestFactory): 23 | view = UserUpdateView() 24 | request = rf.get("/fake-url/") 25 | request.user = user 26 | 27 | view.request = request 28 | 29 | assert view.get_success_url() == f"/users/{user.username}/" 30 | 31 | def test_get_object(self, user: User, rf: RequestFactory): 32 | view = UserUpdateView() 33 | request = rf.get("/fake-url/") 34 | request.user = user 35 | 36 | view.request = request 37 | 38 | assert view.get_object() == user 39 | 40 | 41 | class TestUserRedirectView: 42 | def test_get_redirect_url(self, user: User, rf: RequestFactory): 43 | view = UserRedirectView() 44 | request = rf.get("/fake-url") 45 | request.user = user 46 | 47 | view.request = request 48 | 49 | assert view.get_redirect_url() == f"/users/{user.username}/" 50 | 51 | 52 | class TestUserDetailView: 53 | def test_authenticated(self, user: User, rf: RequestFactory): 54 | request = rf.get("/fake-url/") 55 | request.user = UserFactory() 56 | 57 | response = user_detail_view(request, username=user.username) 58 | 59 | assert response.status_code == 200 60 | 61 | def test_not_authenticated(self, user: User, rf: RequestFactory): 62 | request = rf.get("/fake-url/") 63 | request.user = AnonymousUser() 64 | 65 | response = user_detail_view(request, username=user.username) 66 | 67 | assert response.status_code == 302 68 | assert response.url == "/accounts/login/?next=/fake-url/" 69 | 70 | def test_case_sensitivity(self, rf: RequestFactory): 71 | request = rf.get("/fake-url/") 72 | request.user = UserFactory(username="UserName") 73 | 74 | with pytest.raises(Http404): 75 | user_detail_view(request, username="username") 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | .installed.cfg 7 | .ipython/ 8 | .Python 9 | .python-version 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | downloads/ 14 | eggs/ 15 | .eggs/ 16 | .mypy_cache/ 17 | wheels/ 18 | *.egg-info/ 19 | *.egg 20 | pip-log.txt 21 | pip-delete-this-directory.txt 22 | 23 | # Unit test / coverage reports 24 | htmlcov/ 25 | .tox/ 26 | .coverage 27 | .coverage.* 28 | .cache 29 | coverage.xml 30 | *.cover 31 | .hypothesis/ 32 | .nyc_output 33 | .pytest_cache/ 34 | 35 | # Django stuff: 36 | staticfiles/ 37 | 38 | # Sphinx documentation 39 | docs/_build/ 40 | 41 | # PyBuilder 42 | target/ 43 | 44 | # Environments 45 | .venv 46 | venv/ 47 | ENV/ 48 | .envs 49 | 50 | 51 | ### VisualStudioCode template 52 | .vscode/* 53 | !.vscode/settings.json 54 | !.vscode/tasks.json 55 | !.vscode/launch.json 56 | !.vscode/extensions.json 57 | 58 | 59 | # Provided default Pycharm Run/Debug Configurations should be tracked by git 60 | # In case of local modifications made by Pycharm, use update-index command 61 | # for each changed file, like this: 62 | # git update-index --assume-unchanged .idea/anwesende.iml 63 | ### JetBrains template 64 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 65 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 66 | 67 | # User-specific stuff: 68 | .idea/**/workspace.xml 69 | .idea/**/tasks.xml 70 | .idea/dictionaries 71 | 72 | # Sensitive or high-churn files: 73 | .idea/**/dataSources/ 74 | .idea/**/dataSources.ids 75 | .idea/**/dataSources.xml 76 | .idea/**/dataSources.local.xml 77 | .idea/**/sqlDataSources.xml 78 | .idea/**/dynamic.xml 79 | .idea/**/uiDesigner.xml 80 | 81 | # Gradle: 82 | .idea/**/gradle.xml 83 | .idea/**/libraries 84 | 85 | # CMake 86 | cmake-build-debug/ 87 | 88 | # Mongo Explorer plugin: 89 | .idea/**/mongoSettings.xml 90 | 91 | ## File-based project format: 92 | *.iws 93 | 94 | ## Plugin-specific files: 95 | 96 | # IntelliJ 97 | out/ 98 | 99 | # mpeltonen/sbt-idea plugin 100 | .idea_modules/ 101 | 102 | # JIRA plugin 103 | atlassian-ide-plugin.xml 104 | 105 | # Cursive Clojure plugin 106 | .idea/replstate.xml 107 | 108 | # Crashlytics plugin (for Android Studio and IntelliJ) 109 | com_crashlytics_export_strings.xml 110 | crashlytics.properties 111 | crashlytics-build.properties 112 | fabric.properties 113 | 114 | 115 | ### anwesende 116 | 117 | anwesende/media/ 118 | anwesende/static/pdf/Datenschutzinformationen-a.nwesen.de.pdf 119 | compose/traefik/traefik.yml 120 | docker-compose.yml 121 | Makefile 122 | .zz_builddir/ 123 | -------------------------------------------------------------------------------- /anwesende/templates/room/qrcodes.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | {% if listtype == "importstep" %} 5 |

{{ settings.SHORTURL_PREFIX }}{% url 'room:qrcodes-byimport' object.pk %}

6 |

7 | Dieser Importschritt hat 8 | {{ object.num_new_rooms }} 9 | {{ object.num_new_rooms|pluralize:"neuen Raum,neue Räume" }} und 10 | {{ object.num_new_seats }} 11 | {{ object.num_new_seats|pluralize:"neuen Sitzplatz,neue Sitzplätze" }} 12 | erzeugt. 13 | {% if object.num_existing_seats %} 14 | In der Liste der QR-Codes unten finden sich außerdem 15 | {{ object.num_existing_rooms }} schon vorher 16 | vorhanden{{ object.num_existing_rooms|pluralize:"er Raum,e Räume" }} 17 | und 18 | {{ object.num_existing_seats }} schon vorher 19 | vorhanden{{ object.num_existing_seats|pluralize:"er Sitzplatz,e Sitzplätze" }}. 20 | {% endif %} 21 |

22 | {% elif listtype == "byrooms" %} 23 | organization: {{ view.organization }}; 24 | department: {{ view.department }}; 25 | building: {{ view.building }}; 26 | room: {{ view.room }}; 27 | row_dist: {{ seats.0.room.row_dist }}; 28 | seat_dist: {{ seats.0.room.seat_dist }}; 29 | {% endif %} 30 |
31 | {% for seat in seats %} 32 |
33 |
34 | QR Code 35 |
36 |
37 | Please scan QR code and register your presence. 38 |
39 | {{ settings.SHORTURL_PREFIX }}{% url 'room:visit' seat.hash %} 40 |
41 | date: 42 | {{seat.room.importstep.when | date:"Y-m-d"}} 43 |
44 | organization: 45 | {{seat.room.organization}} 46 |
47 | department: 48 | {{seat.room.department}} 49 |
50 | building: 51 | {{seat.room.building}} 52 |
53 | room: 54 | {{seat.room.room}} 55 |
56 | 57 | {{seat.seatname}} 58 |
59 |
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 |
54 | {% csrf_token %} 55 | {{ form|crispy }} 56 | 57 |
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 |

QR-Codes für vorhandene Räume

6 | 7 | {% if type == "overview" %} 8 | 9 |

Hochschuleinheiten (departments)

10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% for dpt in departments %} 21 | 22 | 23 | 24 | 27 | 28 | {% endfor %} 29 | 30 |
OrganizationDepartment#Buildings
{{ dpt.organization }}{{ dpt.department }}{{ dpt.buildings }} Gebäude
31 |

Zurück

32 | 33 | {% elif type == "department" %} 34 | 35 |

Gebäude von {{ view.department }}

36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {% for bldg in buildings %} 47 | 48 | 49 | 52 | 55 | 56 | {% endfor %} 57 | 58 |
Building#RoomsQR codes
{{ bldg.building }}{{ bldg.rooms }} Räume((alle))
59 |

Zurück

60 | 61 | {% elif type == "building" %} 62 | 63 |

Räume von Gebäude {{ view.building }}

64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | {% for room in rooms %} 76 | 77 | 80 | 81 | 82 | 83 | 84 | {% endfor %} 85 | 86 |
Roomrow_distseat_distseat_last
{{ room.room }}{{ room.row_dist }}{{ room.seat_dist }}{{ room.seat_last }}
87 |

Zurück

88 | 89 | {% else %} 90 | 91 |

type = "{{ type }}". Wie, bitte?

92 | 93 | {% endif %} 94 | 95 | {% endblock content %} 96 | -------------------------------------------------------------------------------- /anwesende/templates/room/home.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

a.nwesen.de

5 | 6 | {% if request.user.is_authenticated %} 7 | 24 | {% endif %} 25 | 26 |

27 | DE: 28 | Eine Open-Source-Webanwendung zur Anwesenheitserfassung. 29 | Dient im Infektionsfall zur Kontaktverfolgung in Zeiten der Pandemie: 30 |

    31 |
  1. 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 |
  2. 39 |
  3. 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 |
  4. 49 |
  5. 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 |
  6. 57 |
  7. 58 | Die Hochschule gibt diese Daten gemäß ihrer gesetzlichen Verpflichtung 59 | an das Gesundheitsamt weiter. 60 |
  8. 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 |

85 | 86 | {% endblock content %} 87 | -------------------------------------------------------------------------------- /.idea/runConfigurations/pytest___.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 56 | -------------------------------------------------------------------------------- /.idea/runConfigurations/pytest__custom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 += ''; 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 + '' + 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 | '
  • ' + 141 | '' + 142 | heading.innerHTML.trim() + 143 | ''; 144 | 145 | // If the last item, close it all out 146 | if (index === len) { 147 | html += getOutdent(Math.abs(startingLevel - currentLevel)); 148 | } 149 | 150 | return html; 151 | 152 | }).join('') + 153 | ''; 154 | }; 155 | 156 | /** 157 | * Initialize the script 158 | */ 159 | var init = function () { 160 | 161 | // Merge any user settings into the defaults 162 | merge(options || {}); 163 | 164 | // Get the headings 165 | // If none are found, don't render a list 166 | headings = contentWrap.querySelectorAll(settings.levels); 167 | if (!headings.length) return; 168 | 169 | // Inject the table of contents 170 | injectTOC(); 171 | 172 | }; 173 | 174 | 175 | // 176 | // Initialize the script 177 | // 178 | 179 | init(); 180 | 181 | }; 182 | -------------------------------------------------------------------------------- /anwesende/room/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import math 3 | from pprint import pprint 4 | import typing as tg 5 | 6 | import django.utils.timezone as djut 7 | from freezegun import freeze_time 8 | import pytest 9 | 10 | import anwesende.room.models as arm 11 | import anwesende.room.tests.makedata as artmd 12 | import anwesende.users.models as aum 13 | import anwesende.utils.date as aud 14 | 15 | 16 | @pytest.mark.django_db 17 | def test_room_descriptor(): 18 | rm1s1, = artmd.make_seats("myroom", 1) 19 | rm2s1, = artmd.make_seats("otherroom", 1) 20 | v1 = artmd.make_visit(rm1s1, "p1", "02:00", "04:00") # noqa 21 | v2 = artmd.make_visit(rm2s1, "p2", "02:00", "04:00") # noqa 22 | myroom = arm.Room.objects.get(room="myroom") 23 | assert myroom.descriptor == "org;dep;bldg;myroom" 24 | 25 | 26 | @pytest.mark.django_db 27 | def test_get_overlapping_visits(): 28 | # test can fail if run very shortly before midnight, just run it again 29 | rm1s1, rm1s2 = artmd.make_seats("room1", 2) 30 | rm2s1, = artmd.make_seats("room2", 1) 31 | targetvisit = artmd.make_visit(rm1s1, "p1", "03:00", "04:00") 32 | shorttargetvisit = artmd.make_visit(rm1s1, "p1", "03:00", "03:01") 33 | # --- the following other visits have _y if they are to be found, _n if not: 34 | otherroom_n = artmd.make_visit(rm2s1, "p2", "03:00", "04:00") 35 | before_n = artmd.make_visit(rm1s2, "p3", "02:00", "03:00") 36 | within_y = artmd.make_visit(rm1s2, "p4", "03:15", "03:45") 37 | across_y = artmd.make_visit(rm1s2, "p5", "02:00", "05:00") 38 | after_n = artmd.make_visit(rm1s2, "p3", "04:00", "05:00") 39 | halfbefore_y = artmd.make_visit(rm1s2, "p6", "02:30", "03:30") 40 | halfafter_y = artmd.make_visit(rm1s2, "p7", "03:30", "04:30") 41 | nearlybefore_n = artmd.make_visit(rm1s2, "p8", "02:00", "03:01") 42 | nearlyafter_n = artmd.make_visit(rm1s2, "p9", "03:59", "05:00") 43 | # --- now look which ones appear for targetvisit: 44 | results = set(targetvisit.get_overlapping_visits()) 45 | result_pks = set(el.pk for el in results) 46 | expected = set(el.pk for el in (targetvisit, within_y, across_y, halfbefore_y, halfafter_y)) 47 | not_expected = set(el.pk for el in (otherroom_n, before_n, after_n, nearlybefore_n, nearlyafter_n)) 48 | print("result_pks", result_pks) 49 | print("expected", expected) 50 | print("not_expected", not_expected) 51 | assert result_pks.isdisjoint(not_expected) 52 | assert result_pks == expected 53 | # --- now look which ones appear for shorttargetvisit: 54 | assert shorttargetvisit.get_overlapping_visits().count() == 0 55 | 56 | 57 | @pytest.mark.django_db 58 | def test_current_unique_visitorsN(): 59 | # test can fail if run very shortly before midnight, just run it again 60 | def show_them(room): 61 | them = room.current_unique_visitors_qs() 62 | print ([v.email for v in them]) 63 | rm1s1, rm1s2, rm1s3 = artmd.make_seats("room1", 3) 64 | rm2s1, = artmd.make_seats("room2", 1) 65 | room = rm1s1.room 66 | person1_early = artmd.make_visit(rm1s1, "p1", "02:58", "04:00") # noqa 67 | person2_ontime = artmd.make_visit(rm1s2, "p2", "03:00", "04:00") # noqa 68 | person3_late = artmd.make_visit(rm1s3, "p3", "03:03", "04:00") # noqa 69 | person4_otherroom = artmd.make_visit(rm2s1, "p4", "03:00", "04:00") # noqa 70 | person3_changed = artmd.make_visit(rm1s1, "p3", "03:30", "04:00") # noqa 71 | # --- now look at different times how many are in rm1: 72 | def freeze_at(ts: str): 73 | return freeze_time(aud.make_dt('now', ts)) 74 | with freeze_at("02:50"): 75 | show_them(room) 76 | assert room.current_unique_visitorsN() == 0 77 | with freeze_at("02:59"): 78 | show_them(room) 79 | assert room.current_unique_visitorsN() == 1 80 | with freeze_at("03:01"): 81 | show_them(room) 82 | assert room.current_unique_visitorsN() == 2 83 | with freeze_at("03:06"): 84 | show_them(room) 85 | assert room.current_unique_visitorsN() == 3 86 | with freeze_at("03:33"): 87 | show_them(room) 88 | assert room.current_unique_visitorsN() == 3 89 | with freeze_at("05:00"): 90 | show_them(room) 91 | assert room.current_unique_visitorsN() == 0 92 | 93 | 94 | @pytest.mark.django_db 95 | def test_get_dummy_seat(): 96 | dummy1 = arm.Seat.get_dummy_seat() 97 | dummy2 = arm.Seat.get_dummy_seat() 98 | assert dummy1 == dummy2 # from DB query, hence not necessarily also 'is' 99 | assert arm.Seat.objects.count() == 1 100 | 101 | 102 | @pytest.mark.django_db 103 | def test_split_seatname(): 104 | dummy = arm.Seat.get_dummy_seat() 105 | dummy.seatnumber = 3 106 | assert dummy.seatname == "r1s3" 107 | assert dummy.seatname == arm.Seat.form_seatname(1, 3) 108 | assert (1, 3) == dummy.split_seatname(dummy.seatname) 109 | 110 | 111 | @pytest.mark.django_db 112 | def test_distance_in_m(): 113 | dummy = arm.Seat.get_dummy_seat() 114 | other = copy.copy(dummy) 115 | other.rownumber = 2 116 | other.seatnumber = 3 117 | r_dist = ((other.rownumber-1) * dummy.room.row_dist) 118 | s_dist = ((other.seatnumber-1) * dummy.room.seat_dist) 119 | dist_is = dummy.distance_in_m(other) 120 | dist_should = math.sqrt(s_dist**2 + r_dist**2) 121 | assert abs(dist_is - dist_should) < 0.0001 122 | -------------------------------------------------------------------------------- /anwesende/users/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | import django.contrib.auth.models 2 | import django.contrib.auth.validators 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [("auth", "0008_alter_user_username_max_length")] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="User", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("password", models.CharField(max_length=128, verbose_name="password")), 27 | ( 28 | "last_login", 29 | models.DateTimeField( 30 | blank=True, null=True, verbose_name="last login" 31 | ), 32 | ), 33 | ( 34 | "is_superuser", 35 | models.BooleanField( 36 | default=False, 37 | help_text="Designates that this user has all permissions without explicitly assigning them.", 38 | verbose_name="superuser status", 39 | ), 40 | ), 41 | ( 42 | "username", 43 | models.CharField( 44 | error_messages={ 45 | "unique": "A user with that username already exists." 46 | }, 47 | help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", 48 | max_length=150, 49 | unique=True, 50 | validators=[ 51 | django.contrib.auth.validators.UnicodeUsernameValidator() 52 | ], 53 | verbose_name="username", 54 | ), 55 | ), 56 | ( 57 | "first_name", 58 | models.CharField( 59 | blank=True, max_length=30, verbose_name="first name" 60 | ), 61 | ), 62 | ( 63 | "last_name", 64 | models.CharField( 65 | blank=True, max_length=150, verbose_name="last name" 66 | ), 67 | ), 68 | ( 69 | "email", 70 | models.EmailField( 71 | blank=True, max_length=254, verbose_name="email address" 72 | ), 73 | ), 74 | ( 75 | "is_staff", 76 | models.BooleanField( 77 | default=False, 78 | help_text="Designates whether the user can log into this admin site.", 79 | verbose_name="staff status", 80 | ), 81 | ), 82 | ( 83 | "is_active", 84 | models.BooleanField( 85 | default=True, 86 | help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", 87 | verbose_name="active", 88 | ), 89 | ), 90 | ( 91 | "date_joined", 92 | models.DateTimeField( 93 | default=django.utils.timezone.now, verbose_name="date joined" 94 | ), 95 | ), 96 | ( 97 | "name", 98 | models.CharField( 99 | blank=True, max_length=255, verbose_name="Name of User" 100 | ), 101 | ), 102 | ( 103 | "groups", 104 | models.ManyToManyField( 105 | blank=True, 106 | help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", 107 | related_name="user_set", 108 | related_query_name="user", 109 | to="auth.Group", 110 | verbose_name="groups", 111 | ), 112 | ), 113 | ( 114 | "user_permissions", 115 | models.ManyToManyField( 116 | blank=True, 117 | help_text="Specific permissions for this user.", 118 | related_name="user_set", 119 | related_query_name="user", 120 | to="auth.Permission", 121 | verbose_name="user permissions", 122 | ), 123 | ), 124 | ], 125 | options={ 126 | "verbose_name_plural": "users", 127 | "verbose_name": "user", 128 | "abstract": False, 129 | }, 130 | managers=[("objects", django.contrib.auth.models.UserManager())], 131 | ) 132 | ] 133 | -------------------------------------------------------------------------------- /anwesende/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static i18n %} 2 | 3 | 4 | 5 | 6 | {% block title %}a.nwesen.de{% endblock title %} 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | {% block css %} 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {% endblock %} 28 | 29 | 30 | 31 | 32 | 33 |
    34 | 66 | 67 |
    68 | 69 |
    70 | 71 | {% if messages %} 72 | {% for message in messages %} 73 |
    {{ message }}
    74 | {% endfor %} 75 | {% endif %} 76 | 77 | {% block content %} 78 |

    Quiet here, isn't it?

    79 | {% endblock content %} 80 | 81 | {% block footer %} 82 | 83 | Impressum 84 | • 85 | Serverbetrieb 86 | • 87 | {{ settings.PRIVACYINFO_DE|safe }} 88 | • 89 | Open Source 90 | 91 | {% endblock footer %} 92 | 93 |
    94 | 95 | {% block modal %}{% endblock modal %} 96 | 97 | 98 | 100 | 101 | {% block javascript %} 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | {% endblock javascript %} 117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /config/settings/production.py: -------------------------------------------------------------------------------- 1 | from .base import * # noqa 2 | from .base import env 3 | 4 | # GENERAL 5 | # ------------------------------------------------------------------------------ 6 | # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key 7 | SECRET_KEY = env("DJANGO_SECRET_KEY") 8 | # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts 9 | ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS") 10 | 11 | # DATABASES 12 | # ------------------------------------------------------------------------------ 13 | DATABASES["default"] = env.db("DATABASE_URL") # noqa F405 14 | DATABASES["default"]["ATOMIC_REQUESTS"] = True # noqa F405 15 | DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60) # noqa F405 16 | 17 | # CACHES 18 | # ------------------------------------------------------------------------------ 19 | # CACHES = { 20 | # "default": { 21 | # "BACKEND": "django_redis.cache.RedisCache", 22 | # "LOCATION": env("REDIS_URL"), 23 | # "OPTIONS": { 24 | # "CLIENT_CLASS": "django_redis.client.DefaultClient", 25 | # # Mimicing memcache behavior. 26 | # # https://github.com/jazzband/django-redis#memcached-exceptions-behavior 27 | # "IGNORE_EXCEPTIONS": True, 28 | # }, 29 | # } 30 | # } 31 | CACHES = { 32 | 'default': { 33 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 34 | 'LOCATION': 'the-one-and-only', 35 | } 36 | } 37 | 38 | # SECURITY 39 | # ------------------------------------------------------------------------------ 40 | DJANGO_HTTPS_INSIST = env.bool("DJANGO_HTTPS_INSIST") # an 'anwesende' pseudo-setting 41 | # https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header 42 | # SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 43 | # https://docs.djangoproject.com/en/dev/ref/settings/#secure-ssl-redirect 44 | SECURE_SSL_REDIRECT = False 45 | # https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-secure 46 | SESSION_COOKIE_SECURE = DJANGO_HTTPS_INSIST 47 | # https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-secure 48 | CSRF_COOKIE_SECURE = DJANGO_HTTPS_INSIST 49 | # https://docs.djangoproject.com/en/dev/topics/security/#ssl-https 50 | # https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-seconds 51 | SECURE_HSTS_SECONDS = 366 * 24 * 3600 if DJANGO_HTTPS_INSIST else 60 52 | # https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-include-subdomains 53 | SECURE_HSTS_INCLUDE_SUBDOMAINS = DJANGO_HTTPS_INSIST 54 | # https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-preload 55 | SECURE_HSTS_PRELOAD = DJANGO_HTTPS_INSIST 56 | # https://docs.djangoproject.com/en/dev/ref/middleware/#x-content-type-options-nosniff 57 | SECURE_CONTENT_TYPE_NOSNIFF = DJANGO_HTTPS_INSIST 58 | 59 | # STATIC and MEDIA 60 | # ------------------------ 61 | STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage" 62 | 63 | # TEMPLATES 64 | # ------------------------------------------------------------------------------ 65 | # https://docs.djangoproject.com/en/dev/ref/settings/#templates 66 | TEMPLATES[-1]["OPTIONS"]["loaders"] = [ # type: ignore[index] # noqa F405 67 | ( 68 | "django.template.loaders.cached.Loader", 69 | [ 70 | "django.template.loaders.filesystem.Loader", 71 | "django.template.loaders.app_directories.Loader", 72 | ], 73 | ) 74 | ] 75 | 76 | # LOGGING 77 | # ------------------------------------------------------------------------------ 78 | # https://docs.djangoproject.com/en/dev/ref/settings/#logging 79 | # https://docs.djangoproject.com/en/dev/topics/logging 80 | LOGGING = { 81 | "version": 1, 82 | "disable_existing_loggers": False, 83 | "filters": { 84 | "require_debug_false": {"()": "django.utils.log.RequireDebugFalse"} 85 | }, 86 | "formatters": { 87 | "verbose": { 88 | "format": "%(levelname)s %(asctime)s %(module)s " 89 | "%(process)d %(thread)d %(message)s" 90 | } 91 | }, 92 | "handlers": { 93 | "mail_admins": { 94 | "level": "ERROR", 95 | "filters": ["require_debug_false"], 96 | "class": "django.utils.log.AdminEmailHandler", 97 | }, 98 | "console": { 99 | "level": "DEBUG", 100 | "class": "logging.StreamHandler", 101 | "formatter": "verbose", 102 | }, 103 | "file": { 104 | "level": "DEBUG", 105 | "class": "logging.FileHandler", 106 | "filename": '/djangolog/django.log', 107 | "formatter": "verbose", 108 | }, 109 | "file_error": { 110 | "level": "ERROR", 111 | "class": "logging.FileHandler", 112 | "filename": '/djangolog/django-errors.log', 113 | "formatter": "verbose", 114 | }, 115 | "file_search": { 116 | "level": "INFO", 117 | "class": "logging.FileHandler", 118 | "filename": '/djangolog/django-requests.log', 119 | "formatter": "verbose", 120 | }, 121 | }, 122 | "loggers": { 123 | "error": { 124 | "handlers": ["console", "file_error"], 125 | "level": "INFO", 126 | "propagate": True, 127 | }, 128 | "search": { 129 | "handlers": ["file_search"], 130 | "level": "INFO", 131 | "propagate": True, 132 | }, 133 | }, 134 | "root": { 135 | "level": "INFO", 136 | "handlers": ["console", "file"] 137 | }, 138 | } 139 | -------------------------------------------------------------------------------- /anwesende/room/tests/test_excel.py: -------------------------------------------------------------------------------- 1 | import re 2 | import typing as tg 3 | 4 | from django.conf import settings 5 | import pytest 6 | 7 | import anwesende.room.excel as are 8 | import anwesende.room.models as arm 9 | import anwesende.room.tests.makedata as artm 10 | import anwesende.users.models as aum 11 | import anwesende.utils.excel 12 | 13 | # #### scaffolding: 14 | 15 | excel_rooms1_filename = "anwesende/room/tests/data/rooms1.xlsx" 16 | 17 | 18 | def excel_example_columnsdict(): 19 | # knowledge about file contents is hardcoded in many places 20 | return anwesende.utils.excel.read_excel_as_columnsdict(excel_rooms1_filename) 21 | 22 | 23 | def check_error_w_patched_example(patcher, msg_elements): 24 | example = excel_example_columnsdict() 25 | patcher(example) 26 | with pytest.raises(are.InvalidExcelError) as err: 27 | are._validate_room_declarations(example) 28 | msg = str(err.value) 29 | for elem in msg_elements: 30 | assert elem in msg 31 | 32 | # #### actual tests: 33 | 34 | 35 | def test_validate_rooms_OK(): 36 | example = excel_example_columnsdict() 37 | are._validate_room_declarations(example) # no exception means success 38 | 39 | 40 | def test_validate_rooms_with_column_missing(): 41 | 42 | def patcher(example): 43 | del example['room'] 44 | check_error_w_patched_example(patcher, ("missing", "room")) 45 | 46 | 47 | def test_validate_rooms_with_additional_column(): 48 | 49 | def patcher(example): 50 | example['surprisecolumn'] = [1, 2] 51 | check_error_w_patched_example(patcher, ("additional", "surprisecolumn")) 52 | 53 | 54 | def test_validate_rooms_with_mixed_organizations(): 55 | 56 | def patcher(example): 57 | example['organization'][1] = "uni-flensburg.de" 58 | check_error_w_patched_example(patcher, 59 | ("multiple", "berlin", "flensburg")) 60 | 61 | 62 | def test_validate_rooms_with_wrong_distances(): 63 | 64 | def patcher1(example): 65 | example['row_dist'][1] = "1,4m" 66 | 67 | check_error_w_patched_example(patcher1, 68 | ("row_dist", "1,4m", "must look like")) 69 | 70 | def patcher2(example): 71 | example['row_dist'][1] = "4,1" 72 | 73 | check_error_w_patched_example(patcher2, 74 | ("row_dist", "4.1", "must be in range")) 75 | 76 | def patcher3(example): 77 | example['row_dist'][1] = "1" 78 | 79 | check_error_w_patched_example(patcher3, 80 | ("row_dist", "1", "must look like")) 81 | 82 | def patcher4(example): 83 | example['seat_dist'][1] = "" 84 | 85 | check_error_w_patched_example(patcher4, 86 | ("seat_dist", "", "must look like")) 87 | 88 | 89 | def test_validate_rooms_with_wrong_seats(): 90 | 91 | def patcher1(example): 92 | example['seat_last'][1] = "Z1S3" 93 | 94 | check_error_w_patched_example(patcher1, 95 | ("seat_last", "Format", "Z1S3")) 96 | 97 | def patcher2(example): 98 | example['seat_last'][1] = "r0s1" 99 | 100 | check_error_w_patched_example(patcher2, 101 | ("seat_last", "rooms must have 1 to 2000 seats", "r0s1")) 102 | 103 | def patcher3(example): 104 | example['seat_last'][1] = "r50s41" 105 | 106 | check_error_w_patched_example(patcher3, 107 | ("seat_last", "rooms must have 1 to 2000 seats", "r50s41")) 108 | 109 | def patcher4(example): 110 | example['seat_last'][1] = "r2s0" 111 | 112 | check_error_w_patched_example(patcher4, 113 | ("seat_last", "rooms must have 1 to 2000 seats", "r2s0")) 114 | 115 | 116 | @pytest.mark.django_db 117 | def test_create_seats_from_excel(): 118 | user = aum.User.objects.create(name="x") 119 | stuff = are.create_seats_from_excel(excel_rooms1_filename, user) 120 | print(stuff) 121 | importstep = arm.Importstep.objects.first() 122 | # ----- check rooms: 123 | all_rooms = arm.Room.objects.filter(organization="fu-berlin.de") 124 | assert all_rooms.count() == 2 125 | print(list(all_rooms)) 126 | qs_room055 = arm.Room.objects.filter(room="055") 127 | assert qs_room055.count() == 1 128 | room055: tg.Optional[arm.Room] = qs_room055.first() 129 | assert room055 and room055.importstep == importstep 130 | # ----- check seats: 131 | print(list(str(s) for s in arm.Seat.objects.all())) 132 | assert arm.Seat.objects.count() == 20 133 | qs_room055_seats = arm.Seat.objects.filter(room=room055) 134 | assert qs_room055_seats.count() == 14 135 | assert set(s.seatname for s in qs_room055_seats) == set(( 136 | "r1s1", "r1s2", "r1s3", "r1s4", "r1s5", "r1s6", "r1s7", 137 | "r2s1", "r2s2", "r2s3", "r2s4", "r2s5", "r2s6", "r2s7")) 138 | myseat2 = qs_room055_seats[2] 139 | myseat5 = qs_room055_seats[5] 140 | assert myseat2.seatname != myseat5.seatname 141 | assert myseat2.hash != myseat5.hash 142 | assert re.fullmatch(r"[0-9a-f]{10}", myseat2.hash) 143 | 144 | 145 | @pytest.mark.django_db 146 | def test_collect_visitgroups(): 147 | artm.make_user_rooms_seats_visits("r2s2", visitsN=4) 148 | targetvisit = arm.Visit.objects.filter(pk=arm.Visit.objects.first().pk) # type: ignore 149 | vrows = are._as_vgrouprows(are.collect_visitgroups(targetvisit)) 150 | assert ("geimpft" in vrows[0].status_3g if settings.USE_STATUS_3G_FIELD # type: ignore 151 | else "unbekannt" in vrows[0].status_3g) # type: ignore 152 | result = set() 153 | for vr in vrows: 154 | vrowstr = f"{vr.familyname}: {vr.room}.{vr.seat} {vr.distance}" # type: ignore 155 | result.add(vrowstr) 156 | should = set([ # see artm for row_dist/seat_dist 157 | "Visitor0: room1.r1s1 0.0m", 158 | "Visitor1: room1.r1s2 0.7m", 159 | "Visitor2: room1.r2s1 1.2m", 160 | "Visitor3: room1.r2s2 1.4m", 161 | ]) 162 | assert result == should 163 | --------------------------------------------------------------------------------